Saltar al contenido

CSRF, security.checkOrigin y cookies SameSite

El cross-site request forgery como el abuso de la autoridad ambiental de las cookies contra las peticiones que mutan estado, la defensa por defecto de Astro con security.checkOrigin que compara la cabecera Origin, el atributo SameSite como segunda barrera en la propia cookie, y cómo actions y endpoints que escriben quedan protegidos frente a los que solo leen.

Qué es CSRF y por qué las mutaciones son el blanco

Para que el CSRF funcione hace falta una propiedad concreta del navegador: envía las cookies de un dominio en toda petición a ese dominio, venga de donde venga el disparo. A eso se le llama autoridad ambiental: la cookie autentica por el mero hecho de estar presente, sin preguntar quién inició la acción. Un <form> oculto en una página hostil, o una imagen que apunta a una URL de tu API, bastan para que el navegador de la víctima dispare una petición con sus cookies pegadas.

HTTP
POST /api/transferir HTTP/1.1Host: banco.exampleOrigin: https://sitio-malicioso.exampleCookie: sesion=... (adjuntada por el navegador, sin querer la victima)

Por eso el blanco es siempre lo que cambia estado, nunca lo que solo lee. Un GET que pinta una página no causa daño aunque se dispare de fuera; un POST, PUT, PATCH o DELETE que transfiere dinero o borra una cuenta, sí. La raíz del ataque es que el servidor no distingue una mutación que tú iniciaste de una que inició otro con tu sesión prestada. La defensa consiste, precisamente, en darle al servidor una forma de notar esa diferencia.

security.checkOrigin: la defensa por defecto

Astro trae esa forma de fábrica. La opción security.checkOrigin —activada por defecto para las rutas renderizadas bajo demanda— comprueba, en cada petición que muta, que la cabecera Origin que el navegador adjunta coincida con el propio sitio. Si un formulario del sitio-malicioso dispara un POST a tu dominio, su Origin delata la procedencia ajena y Astro rechaza la petición antes de que toque tu lógica.

TypeScript
// astro.config.mjsimport { defineConfig } from 'astro/config';export default defineConfig({  output: 'server',  security: {    checkOrigin: true, // valor por defecto; documentarlo es afirmar la postura  },});

La elegancia está en de qué se apoya. El navegador coloca la cabecera Origin por su cuenta en las peticiones que cambian estado, y una página no puede falsificarla: es territorio del navegador, no del JavaScript. Astro solo tiene que verificar que ese Origin, puesto por una autoridad que el atacante no controla, sea el tuyo. La comprobación se dirige justo a las peticiones peligrosas —las que mutan con los tipos de contenido que un formulario cruza sin preflight— y deja pasar los GET inocuos sin fricción.

Vale la pena situar esta defensa frente a la clásica, el token sincronizador: durante años, la protección canónica consistió en incrustar en cada formulario un token secreto e impredecible que el servidor emitía y luego verificaba, de modo que el sitio ajeno, incapaz de leer ese token, no pudiera forjar un envío válido. Funciona, pero cuesta: hay que generar, almacenar, rotar y validar el token formulario a formulario. La comprobación de Origin alcanza el mismo fin apoyándose en una señal que el navegador ya emite y que el atacante no puede falsear, sin estado extra que administrar. Por eso Astro elige ese camino por defecto: la misma garantía con una fracción de la maquinaria.

Cookies SameSite: la segunda barrera

checkOrigin actúa en el servidor; el atributo SameSite actúa en la cookie misma, cortando el problema en su raíz. Al marcar una cookie como SameSite=Lax o SameSite=Strict, le dices al navegador que no la adjunte en peticiones que nacen de otro sitio. Con eso, la petición forjada llega sin la cookie de sesión y por tanto sin autenticar: el ataque muere antes de empezar, porque su premisa —la cookie viaja siempre— deja de cumplirse.

TypeScript
context.cookies.set('sesion', valor, {  httpOnly: true,   // invisible al JavaScript: mitiga el robo por XSS  secure: true,     // solo por HTTPS  sameSite: 'lax',  // no viaja en mutaciones cross-site  path: '/',});

Lax es el equilibrio habitual: la cookie viaja en navegaciones de nivel superior —seguir un enlace a tu sitio— pero no en los POST cross-site ni en las peticiones de fondo, que es donde vive el CSRF. Strict es más severo y no la manda en ningún cruce, a costa de que quien llegue desde un enlace externo aparezca sin sesión. Junto a SameSite, las otras dos banderas del ejemplo completan la higiene: httpOnly esconde la cookie del JavaScript —de modo que un XSS no la lea— y secure la ata a HTTPS. Las tres juntas son el estándar mínimo de una cookie de sesión.

Un aviso para no dormirse sobre SameSite: es una defensa del navegador, no un sustituto de la del servidor. Un cliente antiguo podría no honrarlo, ciertos flujos de nivel superior con Lax dejan pasar navegaciones, y un subdominio tuyo comprometido cuenta como same-site y sortea la barrera. Por eso SameSite y checkOrigin se llevan juntos: uno actúa en la cookie y el otro en el servidor, y el hueco que deja el primero lo tapa el segundo. Fiarlo todo a una sola de las dos capas es dejar entornada justo la puerta que la otra habría cerrado.

Actions y endpoints: proteger lo que escribe

En la práctica, las mutaciones de tu sitio salen por dos caños, y conviene saber cómo queda cada uno. Las actions de Astro llevan protección contra CSRF incorporada: al enviarse verifican la procedencia, así que una mutación expresada como action está cubierta sin trabajo extra. Los endpoints que escriben —tus POST, PUT, DELETE en archivos de src/pages— quedan bajo el paraguas de checkOrigin mientras esté activo, que lo está por defecto.

TypeScript
// src/pages/api/borrar.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = async ({ request, locals }) => {  // checkOrigin ya valido el Origin antes de llegar aqui;  // aun asi, confirma que la sesion tiene permiso para esta accion.  if (!locals.usuario) return new Response('No autorizado', { status: 401 });  // ... la mutacion  return new Response(null, { status: 204 });};

La regla mental para no dejar huecos: cada handler que cambia algo es una mutación y merece las dos defensas —la de procedencia, que checkOrigin o la action te dan, y la de autorización, que es tuya: comprobar que esta sesión concreta tiene permiso para esta acción concreta—. checkOrigin responde “esta petición vino de mi sitio”; no responde “este usuario puede hacer esto”. Confundir las dos preguntas es dejar abierta la que el framework no cierra por ti.