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
Randomnormal. 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.
record Cookie(String name, String value, String sameSite) { String header() { return name + "=" + value + "; HttpOnly; Secure; SameSite=" + sameSite + "; Path=/"; }}
// site: el sitio de la página de la que sale la solicitudrecord Request(String site, String method, String path, Map<String, String> cookies, Map<String, String> form) {}class Shop { static final SecureRandom RANDOM = new SecureRandom(); final String sameSite; final Map<String, String> sessions = new HashMap<>(); // id → comprador final Map<String, String> addresses = new HashMap<>();
Shop(String sameSite) { this.sameSite = sameSite; }
static String newId() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding() .encodeToString(bytes); }
Cookie login(String user, String oldId) { // un id nuevo en cada inicio de sesión; el viejo ya no vale if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, user); return new Cookie("session", id, sameSite); }
String who(Request request) { return sessions.get(request.cookies().get("session")); }}SecureRandom da bytes criptográficamente aleatorios, y Base64.getUrlEncoder().withoutPadding() 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 = new Shop("Lax");Browser browser = new Browser();Cookie first = shop.login("maria", null);Cookie second = shop.login("maria", first.value()); // inicia sesión otra vezSystem.out.println("id: " + second.value().length() + " caracteres");System.out.println("los dos inicios de sesión dan ids distintos: " + (first.value().equals(second.value()) ? "no" : "sí"));System.out.println("Set-Cookie: " + second.header().replace(second.value(), "…"));browser.store(second);System.out.println("el id nuevo → " + visit(shop, browser));browser.store(first);System.out.println("el id viejo → " + visit(shop, browser));browser.store(new Cookie("session", "123456", "Lax"));System.out.println("un id inventado → " + visit(shop, browser));id: 43 caractereslos dos inicios de sesión dan ids distintos: síSet-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/el id nuevo → mariael id viejo → nadieun id inventado → nadieLos 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/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // sin sesión addresses.put(user, request.form().get("address")); return 200;}Cookie login(String user, String oldId) { // un id nuevo en cada inicio de sesión; el viejo ya no vale if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, user); return new Cookie("session", id, sameSite);}// POST /account/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // sin sesión addresses.put(user, request.form().get("address")); return 200;}static boolean attach(Cookie cookie, String site, String method) { // el mismo sitio: la cookie va siempre if (site.equals("shop.example")) return true; return switch (cookie.sameSite()) { case "None" -> true; case "Lax" -> method.equals("GET"); default -> false; // Strict };}int changeAddress(Request request) { Session s = session(request); if (s == null) return 401; // sin sesión String sent = request.form().getOrDefault("csrf", ""); if (!same(sent, s.csrf())) return 403; addresses.put(s.user(), request.form().get("address")); return 200;}Paso a paso, en palabras
- María inicia sesión en shop.example. La contraseña es correcta, así que la tienda le abre una sesión.
- El id de sesión son 32 bytes aleatorios. La tienda recuerda: este id es María.
- El id vuelve en la cookie de sesión. A partir de ahora el navegador la envía en cada solicitud a la tienda.
- 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.
- La cookie es SameSite=None, así que el navegador la pone en la solicitud. El manejador encuentra a María por ella.
- 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.
- 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.
- El mismo formulario, pero sin la cookie el manejador no ve ninguna sesión: 401. La dirección sigue siendo Calle Mayor 15.
- 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.
- Incluso con SameSite=None la otra página no puede leer ese token. Su formulario llega con la cookie pero sin el token: 403.
- El formulario de la tienda envía el token, y coincide: 200, Calle Nueva 7.
- 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 { final Map<String, Cookie> jar = new HashMap<>(); // las cookies de shop.example
void store(Cookie cookie) { jar.put(cookie.name(), cookie); }
// una página de algún sitio envía una solicitud a la tienda Request send(String site, String method, String path, Map<String, String> form) { Map<String, String> cookies = new HashMap<>(); for (Cookie c : jar.values()) { if (attach(c, site, method)) cookies.put(c.name(), c.value()); } return new Request(site, method, path, cookies, form); }
static boolean attach(Cookie cookie, String site, String method) { // el mismo sitio: la cookie va siempre if (site.equals("shop.example")) return true; return switch (cookie.sameSite()) { case "None" -> true; case "Lax" -> method.equals("GET"); default -> false; // Strict }; }}El manejador del problema confía en la cookie:
// POST /account/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // sin sesión addresses.put(user, request.form().get("address")); return 200;}María, la tienda y los dos formularios, primero con SameSite=None, después con Lax:
for (String sameSite : List.of("None", "Lax")) { Shop shop = new Shop(sameSite); Browser browser = new Browser(); browser.store(shop.login("maria", null)); shop.addresses.put("maria", "Calle Mayor 15"); System.out.println("SameSite=" + sameSite); // una página de otro sitio envía su formulario a la tienda show(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "Calle del Engaño 13"))); // el formulario de la propia tienda show(shop, browser.send("shop.example", "POST", "/account/address", Map.of("address", "Calle Nueva 7")));}SameSite=None free-gifts.example → 200, dirección: Calle del Engaño 13 shop.example → 200, dirección: Calle Nueva 7SameSite=Lax free-gifts.example → 401, dirección: Calle Mayor 15 shop.example → 200, dirección: Calle Nueva 7Con 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?
Cookie lax = new Cookie("session", "…", "Lax");Cookie strict = new Cookie("session", "…", "Strict");System.out.println(Browser.attach(lax, "free-gifts.example", "GET"));System.out.println(Browser.attach(lax, "free-gifts.example", "POST"));System.out.println(Browser.attach(strict, "free-gifts.example", "GET"));System.out.println(Browser.attach(strict, "shop.example", "POST"));Lax deja ir la cookie desde otro sitio solo con GET, así que el primero es sí y el segundo es no. Strict nunca la deja ir desde otro sitio, pero en su propio sitio va siempre, también con POST.
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.
Cookie login(String user, String oldId) { if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, new Session(user, newId())); // y un token CSRF para los formularios return new Cookie("session", id, sameSite);}
Session session(Request request) { return sessions.get(request.cookies().get("session"));}
// la página de la tienda pone el token en un campo ocultoString formToken(Request request) { return session(request).csrf();}int changeAddress(Request request) { Session s = session(request); if (s == null) return 401; // sin sesión String sent = request.form().getOrDefault("csrf", ""); if (!same(sent, s.csrf())) return 403; addresses.put(s.user(), request.form().get("address")); return 200;}static boolean same(String a, String b) { // compara todos los bytes, sin detenerse en la primera diferencia return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));}Incluso con SameSite=None el otro formulario se detiene:
Shop shop = new Shop("None");Browser browser = new Browser();browser.store(shop.login("maria", null));shop.addresses.put("maria", "Calle Mayor 15");System.out.println("SameSite=None + CSRF");show(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "Calle del Engaño 13")));// el otro sitio no conoce el token y prueba al azarshow(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "Calle del Engaño 13", "csrf", "123456")));// la página de la tienda pone el token en un campo ocultoString token = shop.formToken(browser.send("shop.example", "GET", "/account", Map.of()));show(shop, browser.send("shop.example", "POST", "/account/address", Map.of("address", "Calle Nueva 7", "csrf", token)));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 7Pruébalo
Completa el hueco
¿Qué función compara los tokens sin detenerse en la primera diferencia?
return MessageDigest.(a.getBytes(UTF_8), b.getBytes(UTF_8));MessageDigest.isEqual mira todos los bytes. Una longitud distinta no es secreto: todos los tokens de la tienda miden 43 caracteres.
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.
El token se comprueba solo si está. El otro formulario simplemente no envía el campo csrf y esquiva la comprobación. Un token que falta tiene que ser un rechazo, igual que uno incorrecto: getOrDefault("csrf", "") y después la comparación, sin condición. Una comprobación de seguridad rechaza ante la duda; no deja pasar.
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 cancelOrder: 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.
otro sitio: 403, pedido 1042: activosin iniciar sesión: 401, pedido 1042: activola tienda: 200, pedido 1042: canceladoMá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
origina la solicitud y rechaza toda solicitud que cambie algo y no venga deshop.example.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: record, switch y patrones · Programación en red · Entrada no confiable · Contraseñas
- Continúa en: JWT y sus trampas · Escalado
- Ejercicios sobre el tema: Seguridad para programadores