wandres.dev
SEGURIDAD · CSP, XSS, cabeceras

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.

⏱ 17 min

El cross-site request forgery es un ataque elegante y contraintuitivo: no roba tu sesión ni inyecta código, sino que usa tu propia sesión contra ti. Un sitio malicioso hace que tu navegador envíe una petición a otro donde ya tienes sesión abierta —tu banco, tu panel—, y como el navegador adjunta las cookies por costumbre, esa petición llega autenticada aunque tú nunca quisiste hacerla. El blanco son siempre las operaciones que mutan: transferir, borrar, cambiar la contraseña. Astro planta la defensa por defecto, pero entender qué comprueba y por qué es lo que te permite no desactivarla por accidente ni dejar un flanco abierto.

🎯 Al terminar esta lección sabrás
  • Entender el CSRF como abuso de la autoridad ambiental de las cookies sobre las mutaciones.
  • Reconocer security.checkOrigin como la comprobación de Origin que Astro activa por defecto.
  • Endurecer las cookies de sesión con el atributo SameSite como segunda barrera.
  • Distinguir por qué se protegen las peticiones que escriben y no las 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.

POST /api/transferir HTTP/1.1
Host: banco.example
Origin: https://sitio-malicioso.example
Cookie: 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.

// astro.config.mjs
import { 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.

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.

// src/pages/api/borrar.ts
import 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.

ℹ️
La regla que hace coherente toda defensa CSRF: GET no muta

Ninguna protección contra el CSRF se sostiene si tu aplicación cambia estado en respuesta a un GET. La semántica de HTTP reserva GET para leer —seguro e idempotente— justamente porque el navegador lo dispara con la mayor facilidad: un <img>, un <link>, una precarga. Si un GET a /borrar?id=5 borra de verdad, ninguna comprobación de Origin te salva, porque las peticiones de lectura no llevan la misma protección ni deben llevarla. La disciplina previa a todo lo demás es honrar los verbos: leer con GET, mutar con POST, PUT, PATCH o DELETE. Solo sobre esa base checkOrigin y SameSite tienen dónde agarrarse.

flowchart TD
MAL[sitio malicioso dispara un POST] --> NAV[el navegador adjunta las cookies]
NAV --> SAME{cookie es SameSite}
SAME -->|si| DROP[el navegador no envia la cookie]
SAME -->|no| ORIGIN{checkOrigin compara el Origin}
ORIGIN -->|Origin ajeno| REJECT[astro rechaza la peticion]
ORIGIN -->|Origin propio| PASS[continua a tu logica]
style DROP fill:#a6e3a1,color:#11111b
style REJECT fill:#f38ba8,color:#11111b
style PASS fill:#89b4fa,color:#11111b
⚠️
No desactives checkOrigin sin saber qué sostiene

La tentación de poner checkOrigin: false aparece cuando una integración legítima —un webhook, un cliente externo— llega con un Origin que no es el tuyo y Astro la rechaza. Apagar la comprobación global para todo el sitio es la cura peor que la enfermedad: reabres el CSRF en cada endpoint que muta. La salida correcta es no expresar esos endpoints máquina-a-máquina como acciones dependientes de cookies, sino autenticarlos con una credencial explícita —un token de API, una firma— que no viaje sola en el navegador. Reserva checkOrigin para lo que sí se maneja con sesión y cookies, que es donde el CSRF vive.

📝
checkOrigin solo aplica bajo demanda

La comprobación de Origin tiene sentido donde hay una petición real que responder, es decir, en rutas renderizadas bajo demanda. Una página estática horneada en el build no procesa envíos ni muta nada, así que la protección no le concierne. Si tu sitio mezcla lo estático y lo dinámico, ten claro que el escudo de checkOrigin cubre exactamente las rutas de servidor, que son las únicas que pueden recibir un POST que cambie estado.

🍪

Autoridad ambiental

El navegador adjunta las cookies siempre. El CSRF abusa de eso para mutar con tu sesion prestada.

🧭

checkOrigin

Activo por defecto. Compara el Origin que pone el navegador y rechaza las mutaciones de procedencia ajena.

🚧

SameSite

En la cookie misma. Con Lax o Strict, el navegador no la envia en peticiones nacidas de otro sitio.

✍️

Procedencia y permiso

checkOrigin dice de donde vino. La autorizacion, que es tuya, dice si este usuario puede hacerlo.

El CSRF nace de confundir la posesión de una credencial con la intención de usarla

El cross-site request forgery enseña, mejor que casi ningún otro ataque, una distinción que atraviesa toda la seguridad: la que separa la autenticación de la autorización, y a ambas de una tercera cosa que solemos dar por supuesta —la intención—. El navegador, al adjuntar la cookie en cada petición, responde afirmativamente a la pregunta “¿quién eres?”: autentica. Pero autenticar no es querer. La cookie prueba que la sesión existe; no prueba que su dueño haya deseado esta acción concreta en este instante. El CSRF se cuela exactamente por esa grieta: toma una credencial válida —la sesión, genuina— y la usa para una intención que no es la del dueño. Es el problema de la autoridad ambiental en estado puro: cuando el permiso flota en el ambiente y se aplica solo, sin que quien actúa tenga que presentarlo a conciencia, cualquiera que consiga que la víctima dispare la petición hereda ese permiso sin poseerlo. La cura conceptual, entonces, no es reforzar la autenticación —la sesión ya era auténtica— sino reintroducir la prueba de intención en las operaciones que importan. Y ahí es donde brilla la comprobación de Origin: no pregunta quién eres, pregunta desde dónde se originó esta acción, y con eso reconstruye una señal de intención que la cookie había perdido. El SameSite ataca el mismo problema desde el otro lado, negándose a hacer ambiental una autoridad que debería ejercerse solo desde el propio sitio. Los dos, juntos, restauran el vínculo entre la credencial y la voluntad de usarla. Hay aquí una moraleja que trasciende el CSRF y llega hasta el diseño de sistemas de permisos enteros: la seguridad se degrada siempre que la autoridad se vuelve ambiental —que actúa por estar presente y no por ser presentada—, y se recompone siempre que se exige, para cada acción sensible, una manifestación deliberada de quién la quiere. Que Astro active checkOrigin por defecto es la afirmación, hecha configuración, de que las mutaciones no deberían fiarse de una autoridad que flota: deben exigir, al menos, la prueba de que nacieron donde dicen. Entender esto es dejar de ver el CSRF como un truco raro con formularios ocultos y empezar a verlo como lo que es —el recordatorio de que tener una llave no es lo mismo que querer abrir la puerta, y de que todo sistema serio debe pedir, para lo que de verdad importa, algo más que la mera posesión de la llave.

⚔️ Cierra el flanco de las mutaciones
  1. Escribe un endpoint POST que mute algo y confirma que, con checkOrigin activo, una petición forjada con un Origin ajeno es rechazada por Astro antes de tu lógica.
  2. Fija la cookie de sesión con httpOnly, secure y sameSite: 'lax', y razona qué cambia si la subes a strict para quien llega desde un enlace externo.
  3. Expresa una mutación como una action y verifica que su protección CSRF incorporada rechaza igualmente el envío de procedencia ajena.
  4. Añade a ese mismo handler una comprobación de autorización —que la sesión tiene permiso para la acción— y explica por qué checkOrigin no la sustituye.