Ir al contenido
wannacode

Sesiones, cookies y CSRF

En resumen: CSRF es un ataque en el que otro sitio envía una solicitud al tuyo y el navegador adjunta la cookie de sesión; las cookies SameSite y un token CSRF lo detienen.

Problema

La dirección que María no cambió

María, una compradora, escribe al soporte de la tienda: su paquete fue a «Calle del Engaño 13», pero ella vive en la «Calle Mayor 15». Su contraseña es fuerte y nadie la conoce. En el registro de la tienda hay una solicitud POST /account/address a las 21:14, con su sesión, desde su navegador.

María jura que a las 21:14 no estaba en la tienda. Estaba en una página de «¡Gana un premio!» en otra pestaña. Nadie robó su contraseña ni su cookie. Entonces, ¿cómo cambió su propio navegador su dirección?

La idea

Cómo recuerda la tienda que has iniciado sesión

HTTP no tiene memoria: cada solicitud va sola (lo viste en la lección sobre la red). Así que después del inicio de sesión la tienda crea una sesión: escribe en una tabla «este id es María» y envía el id al navegador en una cookie, con el encabezado Set-Cookie. A partir de entonces el navegador envía la cookie en cada solicitud a la tienda por sí solo, y la tienda encuentra a María por el id.

El id es como un número de guardarropa: quien lo tiene se lleva el abrigo. Por eso tiene que ser:

  • aleatorio: 32 bytes de un generador criptográfico, no un contador ni un Random normal. Si no, el siguiente id se puede adivinar;
  • HttpOnly: los scripts de la página no lo ven. Ni siquiera un script ajeno introducido con XSS puede leerlo;
  • Secure: solo va por HTTPS, nunca por un canal abierto;
  • SameSite: cuando va a la tienda desde una página de otro sitio. Esa es la segunda mitad de la lección.

La contraseña se comprueba una vez, al iniciar sesión (como en la lección sobre contraseñas), y después solo funciona el id. Por eso en cada inicio de sesión la tienda da un id nuevo y olvida el viejo. El porqué está en «Más precisamente, para curiosos».

Míralo

La sesión en el código

La cookie y la solicitud son objetos. El sitio del que viene la solicitud es un campo: así, en un proceso y sin red, ves todo lo que hacen el navegador y el servidor.

@dataclass
class Cookie:
name: str
value: str
same_site: str
def header(self):
return (f"{self.name}={self.value}; HttpOnly; Secure; "
f"SameSite={self.same_site}; Path=/")
@dataclass
class Request:
site: str # el sitio de la página de la que sale la solicitud
method: str
path: str
cookies: dict
form: dict
class Shop:
def __init__(self, same_site):
self.same_site = same_site
self.sessions = {} # id → comprador
self.addresses = {}
def login(self, user, old_id=None):
# un id nuevo en cada inicio de sesión; el viejo ya no vale
self.sessions.pop(old_id, None)
session_id = secrets.token_urlsafe(32)
self.sessions[session_id] = user
return Cookie("session", session_id, self.same_site)
def who(self, request):
return self.sessions.get(request.cookies.get("session"))
secrets.token_urlsafe(32) toma 32 bytes criptográficamente aleatorios y los convierte en texto que cabe en una cookie: 43 caracteres.

María inicia sesión dos veces, y después el navegador prueba el id nuevo, el viejo y uno inventado:

shop = Shop("Lax")
browser = Browser()
first = shop.login("maria")
second = shop.login("maria", first.value) # inicia sesión otra vez
print(f"id: {len(second.value)} caracteres")
print("los dos inicios de sesión dan ids distintos:", "no" if first.value == second.value else "sí")
print("Set-Cookie:", second.header().replace(second.value, "…"))
browser.store(second)
print("el id nuevo →", visit(shop, browser))
browser.store(first)
print("el id viejo →", visit(shop, browser))
browser.store(Cookie("session", "123456", "Lax"))
print("un id inventado →", visit(shop, browser))
Salida
id: 43 caracteres
los dos inicios de sesión dan ids distintos: sí
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/
el id nuevo → maria
el id viejo → nadie
un id inventado → nadie

Los ids son distintos en cada ejecución, así que el programa imprime solo la longitud y «sí/no». Después del segundo inicio de sesión el id viejo no lleva a nadie.

La idea

El navegador envía la cookie por sí solo

Esto es lo que tenía la página «¡Gana un premio!»:

<form action="https://shop.example/account/address" method="post">
<input type="hidden" name="address" value="Calle del Engaño 13">
</form>
<script>document.forms[0].submit()</script>

Un formulario oculto hacia la tienda y una línea que lo envía de inmediato. El atacante no conoce la cookie de María y no la necesita. El navegador ve una solicitud a shop.example y añade la cookie de la tienda por sí solo. El manejador encuentra a María por ella y cambia la dirección.

Esto se llama CSRF (cross-site request forgery). La cookie demuestra quién está en el navegador, pero no qué página inició la solicitud. Un manejador que confía solo en la cookie confía en todas las páginas de internet que abre María.

Míralo

El otro formulario y las dos defensas

# POST /account/address
def change_address(self, request):
user = self.who(request)
if user is None:
return 401 # sin sesión
self.addresses[user] = request.form["address"]
return 200
Paso a paso, en palabras
  1. María inicia sesión en shop.example. La contraseña es correcta, así que la tienda le abre una sesión.
  2. El id de sesión son 32 bytes aleatorios. La tienda recuerda: este id es María.
  3. El id vuelve en la cookie de sesión. A partir de ahora el navegador la envía en cada solicitud a la tienda.
  4. En otra pestaña María abre free-gifts.example. Su página tiene un formulario oculto que envía a la tienda y se envía solo.
  5. La cookie es SameSite=None, así que el navegador la pone en la solicitud. El manejador encuentra a María por ella.
  6. Confía en la cookie y cambia la dirección: 200, Calle del Engaño 13. María no hizo clic en nada en la tienda.
  7. Arreglo 1: SameSite=Lax. En un POST desde otro sitio el navegador deja la cookie en casa. Las páginas de la tienda la siguen recibiendo.
  8. El mismo formulario, pero sin la cookie el manejador no ve ninguna sesión: 401. La dirección sigue siendo Calle Mayor 15.
  9. Arreglo 2: un token CSRF. La sesión guarda un token aleatorio, y la página de la tienda lo pone en un campo oculto de su formulario.
  10. Incluso con SameSite=None la otra página no puede leer ese token. Su formulario llega con la cookie pero sin el token: 403.
  11. El formulario de la tienda envía el token, y coincide: 200, Calle Nueva 7.
  12. La cookie dice quién eres. Solo el token dice que la solicitud viene de una página de la tienda.

El navegador decide si la cookie va. La regla está en el atributo SameSite: None (va siempre), Lax (desde otro sitio solo con GET) y Strict (desde otro sitio nunca).

class Browser:
def __init__(self):
self.jar = {} # las cookies de shop.example
def store(self, cookie):
self.jar[cookie.name] = cookie
# una página de algún sitio envía una solicitud a la tienda
def send(self, site, method, path, form=None):
cookies = {c.name: c.value for c in self.jar.values()
if attach(c, site, method)}
return Request(site, method, path, cookies, form or {})
def attach(cookie, site, method):
# el mismo sitio: la cookie va siempre
if site == "shop.example":
return True
if cookie.same_site == "None":
return True
if cookie.same_site == "Lax":
return method == "GET"
return False # Strict

El manejador del problema confía en la cookie:

# POST /account/address
def change_address(self, request):
user = self.who(request)
if user is None:
return 401 # sin sesión
self.addresses[user] = request.form["address"]
return 200

María, la tienda y los dos formularios, primero con SameSite=None, después con Lax:

for same_site in ["None", "Lax"]:
shop = Shop(same_site)
browser = Browser()
browser.store(shop.login("maria"))
shop.addresses["maria"] = "Calle Mayor 15"
print(f"SameSite={same_site}")
# una página de otro sitio envía su formulario a la tienda
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
{"address": "Calle del Engaño 13"}))
# el formulario de la propia tienda
show(shop, browser.send("shop.example", "POST", "/account/address",
{"address": "Calle Nueva 7"}))
Salida
SameSite=None
free-gifts.example → 200, dirección: Calle del Engaño 13
shop.example → 200, dirección: Calle Nueva 7
SameSite=Lax
free-gifts.example → 401, dirección: Calle Mayor 15
shop.example → 200, dirección: Calle Nueva 7

Con Lax el otro formulario recibe 401, y el formulario de la propia tienda pasa: viene del mismo sitio.

Pruébalo

Predice la salida

¿Cuándo va la cookie? ¿Qué imprime esto?

lax = Cookie("session", "…", "Lax")
strict = Cookie("session", "…", "Strict")
print(attach(lax, "free-gifts.example", "GET"))
print(attach(lax, "free-gifts.example", "POST"))
print(attach(strict, "free-gifts.example", "GET"))
print(attach(strict, "shop.example", "POST"))
Respuesta

La idea

El token que la otra página no puede leer

SameSite es una defensa del navegador. Es buena, pero sola no basta: los navegadores viejos, los subdominios del mismo sitio y los manejadores que cambian algo con GET la eluden. Por eso la tienda tiene también una defensa propia: un token CSRF.

Al iniciar sesión la sesión recibe un valor aleatorio más, el token. La página de la tienda lo pone en un campo oculto de su formulario. El manejador acepta la solicitud solo si el token del formulario coincide con el de la sesión.

La otra página puede enviar una solicitud a la tienda, pero no puede leer la respuesta: el navegador no la deja (es la política del mismo origen). Así que no conoce el token y no puede ponerlo en el formulario.

def login(self, user, old_id=None):
self.sessions.pop(old_id, None)
session_id = secrets.token_urlsafe(32)
# y un token CSRF para los formularios
self.sessions[session_id] = Session(user, secrets.token_urlsafe(32))
return Cookie("session", session_id, self.same_site)
def session(self, request):
return self.sessions.get(request.cookies.get("session"))
# la página de la tienda pone el token en un campo oculto
def form_token(self, request):
return self.session(request).csrf
def change_address(self, request):
s = self.session(request)
if s is None:
return 401 # sin sesión
sent = request.form.get("csrf", "").encode()
if not hmac.compare_digest(sent, s.csrf.encode()):
return 403
self.addresses[s.user] = request.form["address"]
return 200
hmac.compare_digest compara el token en tiempo constante: mira todos los bytes, sin detenerse en la primera diferencia. Si no, el tiempo de respuesta dice cuánto del comienzo es correcto. (.encode() está ahí porque con texto solo acepta ASCII.)

Incluso con SameSite=None el otro formulario se detiene:

shop = Shop("None")
browser = Browser()
browser.store(shop.login("maria"))
shop.addresses["maria"] = "Calle Mayor 15"
print("SameSite=None + CSRF")
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
{"address": "Calle del Engaño 13"}))
# el otro sitio no conoce el token y prueba al azar
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
{"address": "Calle del Engaño 13", "csrf": "123456"}))
# la página de la tienda pone el token en un campo oculto
token = shop.form_token(browser.send("shop.example", "GET", "/account"))
show(shop, browser.send("shop.example", "POST", "/account/address",
{"address": "Calle Nueva 7", "csrf": token}))
Salida
SameSite=None + CSRF
free-gifts.example → 403, dirección: Calle Mayor 15
free-gifts.example → 403, dirección: Calle Mayor 15
shop.example → 200, dirección: Calle Nueva 7

Pruébalo

Completa el hueco

¿Qué función compara los tokens sin detenerse en la primera diferencia?

if not hmac.(sent, s.csrf.encode()):
    return 403

Pruébalo

Encuentra el error

Un compañero añade un token CSRF y las pruebas pasan: un token incorrecto da 403. Una semana después se vuelven a cambiar direcciones desde otro sitio. ¿Qué línea tiene el error?

Elige la línea con el error.

Más precisamente, para curiosos

Fijación de sesión (session fixation). El atacante obtiene un id de la tienda (por ejemplo como invitado) y se lo planta a María: en un enlace, en una cookie de un subdominio. Si la tienda conserva el mismo id después de su inicio de sesión, el atacante ha iniciado sesión como María. Por eso al iniciar sesión se borra el id viejo y se da uno nuevo, como hace login en la lección.

Cerrar sesión. Al cerrar sesión se borra la sesión en el servidor, no solo la cookie del navegador. Si no, un id robado sigue funcionando después de cerrar sesión.

Lax, más precisamente. Lax deja ir la cookie desde otro sitio solo en una navegación de la página entera con un método «seguro» (GET, HEAD): cuando María hace clic en un enlace a la tienda. Las solicitudes ocultas con fetch o en un <iframe> no la reciben. Por eso GET nunca debe cambiar nada: si no, basta un enlace para el ataque. Si la cookie no tiene SameSite, Chrome y Edge la tratan como Lax, pero Firefox y Safari no siempre. Por eso el atributo se escribe de forma explícita.

Comprobar Origin. El navegador envía los encabezados Origin y Sec-Fetch-Site, que dicen de dónde viene la solicitud, y una página de otro sitio no puede falsificarlos. Comprobarlos es una buena tercera capa.

En un proyecto real el token lo hace el framework: Spring Security (activado por defecto), ASP.NET Core antiforgery, Django (CsrfViewMiddleware), y en Express una biblioteca como csrf-csrf. Las sesiones también vienen hechas: HttpSession en Java, ISession en ASP.NET Core, las sesiones de Django y Flask, express-session. El prefijo __Host- en el nombre de la cookie (__Host-session) hace que el navegador exija Secure, Path=/ y que no haya Domain.

HTTPS (los certificados y la configuración del servidor) es parte de la infraestructura y se enseña en el curso de DevOps ZeroToOps.

Ejercicio

Cancelar un pedido

Resuélvelo en tu propio editor.

La tienda tiene también un formulario «Cancelar pedido»: POST /orders/cancel con un campo order. Escribe el manejador cancel_order: sin sesión devuelve 401, sin un token CSRF correcto 403, y si no marca el pedido como cancelado y devuelve 200. Pruébalo con tres solicitudes, con la cookie SameSite=None: desde el otro sitio con la cookie de María pero sin token; desde un navegador que no ha iniciado sesión; desde la página de la tienda con el token.

Salida
otro sitio: 403, pedido 1042: activo
sin iniciar sesión: 401, pedido 1042: activo
la tienda: 200, pedido 1042: cancelado

Más tareas

  • Un token nuevo. Da un token CSRF nuevo después de cada cambio de dirección correcto. ¿Qué pasa si María tiene el formulario abierto en dos pestañas?
  • Origin. Añade un campo origin a la solicitud y rechaza toda solicitud que cambie algo y no venga de shop.example.

Test

Ponte a prueba

1. ¿Por qué la cookie de sesión lleva HttpOnly?
2. ¿Qué aprovecha un ataque CSRF?
3. ¿Por qué el token CSRF detiene el otro formulario aunque la cookie siga yendo?
4. ¿Por qué la tienda da un id de sesión nuevo en cada inicio de sesión?
5. ¿Por qué el token se compara con MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual y no con equals o ==?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·