Nivel 2 · 25 min
CSRF: Defensa en Profundidad Same-Origin
Cross-Site Request Forgery (CSRF) abusa del hábito del browser de adjuntar cookies a cada request a un dado origin, sin importar quién inició la request. Si una víctima está logueada en bank.com y visita attacker.com, un formulario oculto en attacker.com puede hacer POST a bank.com/transfer y el banco recibe una request autenticada. El fix es romper la suposición de que ''cookies presentes = intención del usuario''.
Cómo Funciona Realmente CSRF
Tres precondiciones: (1) la víctima está autenticada en el sitio target (cookie de sesión presente); (2) el sitio target usa cookies para autenticación (los headers Bearer no se adjuntan automáticamente, así que las APIs que usan Authorization: Bearer ... no son vulnerables en el sentido clásico); (3) el sitio target tiene un endpoint que cambia estado y no valida la intención del usuario más allá de la cookie de sesión. La página del atacante envía un formulario oculto '<'form action="https://bank.com/transfer" method="POST"'>''<'input name="to" value="attacker"'>''<'input name="amount" value="5000"'>''<'/form'>' seguido de document.forms[0].submit(). El browser obedientemente incluye la cookie de sesión del banco. El servidor del banco ve una sesión válida y procesa la transferencia.
Cookies SameSite — El Default Moderno
Desde 2020, Chrome y Firefox tratan las cookies sin SameSite explícito como SameSite=Lax. Lax significa que la cookie solo se envía en GETs cross-site de nivel superior (un usuario clickeando un link a tu sitio) — no en POSTs / fetches cross-site. Esto elimina el CSRF clásico para endpoints correctamente categorizados (los cambios de estado son POST/PUT/DELETE; las lecturas seguras son GET e idempotentes). SameSite=Strict va más lejos: la cookie no se envía en ninguna request cross-site, incluyendo clicks en links — lo que rompe el UX de un usuario que clickea un link en un DM de Slack para llegar a una página autenticada. La mayoría de las apps quieren Lax para la cookie de sesión. SameSite=None requiere Secure (HTTPS) y es para flujos third-party explícitos (por ejemplo, widgets SaaS embebidos). La trampa: SameSite es un atributo de cookie, no un mecanismo de autenticación — un CDN o subdominio mal configurado puede saltearlo.
Tokens y Headers como Defensa en Profundidad
Patrón synchronizer token: el servidor emite un token CSRF inadivinable por sesión (128 bits aleatorios), lo embebe en cada formulario como campo oculto y en estado accesible desde JS, y valida que los POSTs entrantes incluyan el token matcheante en un campo oculto o header X-CSRF-Token. Como el sitio del atacante no puede leer el token (la Same-Origin Policy bloquea la lectura de la respuesta de bank.com), el formulario forjado tiene el token equivocado (o ninguno) y la request se rechaza. Double-submit cookie: variante stateless donde el servidor manda el token en una cookie y espera que el cliente lo eche en un header custom — funciona porque el atacante puede setear su propia cookie pero no puede leer el valor de la cookie de la víctima. CSRF solo importa para endpoints que cambian estado — un endpoint GET estrictamente idempotente no es un vector CSRF (pero si tu GET tiene side effects, eso es otro bug). Las SPAs modernas que usan headers Authorization: Bearer (no cookies) esquivan CSRF totalmente — al costo de necesitar un lugar seguro para guardar el token (ver lección sec-006 sobre OAuth/PKCE).
Code example
// Spring Security — protección CSRF habilitada por defecto para sesiones
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/webhooks/**")) // excluí webhooks (tienen su propia auth)
.sessionManagement(s -> s.sessionFixation().migrateSession())
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
// Setear la cookie de sesión correctamente
response.addHeader("Set-Cookie",
"SESSION=" + sessionId + "; " +
"HttpOnly; " + // sin acceso desde JS
"Secure; " + // solo HTTPS
"SameSite=Lax; " + // defensa CSRF
"Path=/; " +
"Max-Age=3600");
// Cliente (SPA) — leer cookie CSRF, echarla en header
const token = document.cookie
.split("; ")
.find(c => c.startsWith("XSRF-TOKEN="))
?.split("=")[1];
fetch("/api/transfer", {
method: "POST",
headers: { "X-CSRF-Token": token, "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({to, amount})
});