Command Palette

Search for a command to run...

ES·EN

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).

Puntos clave

  • Las cookies SameSite=Lax (el default moderno) eliminan el CSRF clásico para endpoints que cambian estado. Confirmá que tu cookie de sesión lo tenga seteado explícitamente.
  • Los endpoints GET deben ser idempotentes y sin side effects. Un GET que dispara /api/delete-account es un vector CSRF aun con SameSite=Lax.
  • Las APIs con Bearer token (header Authorization) no son vulnerables a CSRF porque los browsers no adjuntan el header automáticamente — pero guardar el token de forma segura se vuelve otro problema.

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})
});