Lección 2 de 614 min
Cookies, CORS y CSRF
Los atributos Secure, HttpOnly y SameSite, los tokens CSRF y cómo funciona de verdad CORS.
Tu usuaria tiene la sesión abierta en tu tienda y, en otra pestaña, entra en una web de ofertas. Esa web puede pedirle a su navegador que envíe peticiones a tu tienda. ¿Viajará con ellas su cookie de sesión? ¿Podrá la web de ofertas leer la respuesta? ¿Podrá cambiar su email sin que se dé cuenta?
Las respuestas dependen de tres mecanismos que se confunden a menudo: los atributos de las cookies, que deciden cuándo viaja una cookie y quién puede leerla; las defensas contra CSRF, que evitan que otra web actúe en nombre del usuario; y CORS, que decide qué respuestas puede leer el JavaScript de otro origen. Al final de la lección los combinarás en un simulador.
Los atributos de las cookies
Una cookie llega en la cabecera Set-Cookie y el navegador la devuelve en la cabecera Cookie de las siguientes peticiones a ese dominio. Los atributos que la acompañan son instrucciones para el navegador: cuándo enviarla, por qué canal y quién puede tocarla.
Cookies Secure
La cookie solo viaja por HTTPS, nunca por una conexión en claro.
- Requisito de:
- SameSite=None
Con Secure, el navegador solo envía la cookie en peticiones HTTPS. Sin él, basta con que el usuario abra un enlace http:// a tu dominio, aunque luego lo redirijas, para que la cookie viaje en claro por una red que alguien puede estar escuchando.
Además, una página servida por HTTP no puede crear ni sobrescribir cookies con Secure, lo que evita que un atacante en la red las reemplace desde la versión sin cifrar de tu web.
Úsalo cuando
En todas las cookies de una web servida por HTTPS, que debería ser toda tu web. Es obligatorio para usar SameSite=None.
Ten en cuenta
Secure protege el transporte, no el contenido: cualquier script de la página puede seguir leyendo la cookie si no lleva también HttpOnly.
Set-Cookie: sesion=a3f9c2; Secure; HttpOnly; SameSite=Lax; Path=/
# Con el prefijo __Host- el navegador exige Secure, Path=/ y ningún Domain
Set-Cookie: __Host-sesion=a3f9c2; Secure; HttpOnly; SameSite=Lax; Path=/Cookies HttpOnly
La cookie viaja con las peticiones, pero ningún JavaScript puede leerla.
- Frena:
- El robo por XSS
Una cookie HttpOnly no aparece en document.cookie ni en ninguna API de JavaScript. El navegador la sigue enviando con cada petición, así que tu servidor la recibe igual, pero un script inyectado con un ataque XSS no puede robarla y llevársela a otro servidor.
Es la protección básica de cualquier cookie de sesión: el código de tu frontend casi nunca necesita leerla, solo que viaje.
Úsalo cuando
En las cookies de sesión y en cualquier cookie que identifique al usuario. Déjalo fuera solo si tu JavaScript necesita leer el valor, como el token del patrón double-submit que verás más abajo.
Ten en cuenta
No es una defensa contra el XSS: el script malicioso no puede leer la cookie, pero sí hacer peticiones desde la página de la víctima, que llevarán la cookie igualmente. Lo verás en la lección de defensas del navegador.
// El servidor envió:
// Set-Cookie: sesion=a3f9c2; Secure; HttpOnly
// Set-Cookie: tema=oscuro; Secure
console.log(document.cookie);
// "tema=oscuro" (la de sesión no aparece)SameSite
Decide si la cookie viaja cuando la petición la inicia otro sitio.
- Valores:
- Strict, Lax, None
- Sin atributo:
- Lax en Chromium
SameSite tiene tres valores. Strict: la cookie solo viaja si la petición nace en tu propio sitio; si alguien llega desde un enlace en otra web, la primera petición va sin ella y parece que no ha iniciado sesión. Lax: además viaja cuando el usuario navega a tu web desde otro sitio con un enlace (un GET de nivel superior), pero no en formularios POST, fetch, imágenes ni iframes lanzados desde otro sitio. None: viaja siempre y exige Secure.
Ojo con la palabra «sitio»: no es lo mismo que «origen». app.tienda.com y blog.tienda.com son orígenes distintos, pero el mismo sitio, tienda.com. Para SameSite, una petición entre ellos no es de otro sitio.
Chrome y los navegadores basados en Chromium tratan como Lax las cookies que llegan sin el atributo, y no todos los navegadores aplican esa regla. Por eso conviene declararlo siempre.
Úsalo cuando
Lax para la cookie de sesión de casi cualquier web; Strict para acciones muy sensibles, como un panel de administración; None solo si tu cookie tiene que funcionar incrustada en otras webs, como un widget o un iframe de pago.
Ten en cuenta
No protege de los ataques que vienen de tu propio sitio: si un subdominio olvidado queda comprometido, para SameSite sus peticiones son legítimas. Y con None, algunos navegadores bloquean o aíslan igualmente las cookies de terceros.
# Sesión normal: funciona al llegar desde un enlace externo
Set-Cookie: sesion=a3f9c2; Secure; HttpOnly; SameSite=Lax
# Panel de administración: nunca desde otro sitio
Set-Cookie: admin=77b1e0; Secure; HttpOnly; SameSite=Strict
# Widget incrustado en webs de terceros
Set-Cookie: widget=k2; Secure; SameSite=NoneCSRF: peticiones en nombre del usuario
El navegador adjunta las cookies de un dominio a las peticiones dirigidas a ese dominio, las inicie quien las inicie. Ese comportamiento, tan cómodo, es justo lo que aprovecha el CSRF.
CSRF
Otra web hace que el navegador de tu usuario envíe una petición a tu servidor con su sesión.
- Siglas:
- Cross-Site Request Forgery
En un ataque CSRF (Cross-Site Request Forgery, falsificación de peticiones entre sitios), la web del atacante contiene un formulario oculto que apunta a tu servidor y se envía solo. El navegador de la víctima adjunta su cookie de sesión y tu servidor, que solo mira la cookie, cree que la petición es legítima: cambia el email, hace una transferencia o borra la cuenta.
El atacante no necesita leer la respuesta, solo que la acción ocurra. Por eso CORS no lo frena: un formulario HTML no es una petición CORS. Lo que sí lo frena es SameSite=Lax o Strict, un token CSRF o comprobar las cabeceras Origin y Sec-Fetch-Site, que indican desde dónde se lanzó la petición.
Úsalo cuando
Ten presente el CSRF siempre que tu autenticación vaya en cookies, que el navegador envía solo. Si tu API usa un token en la cabecera Authorization que añade tu JavaScript, el CSRF clásico no aplica, porque el atacante no puede añadir esa cabecera.
Ten en cuenta
Un GET nunca debe cambiar nada: con SameSite=Lax la cookie sí viaja cuando el usuario sigue un enlace desde otro sitio, así que un simple enlace a …/borrar-cuenta bastaría para lanzar la acción.
<!-- La víctima ni siquiera ve el formulario -->
<form id="f" method="POST" action="https://app.tienda.com/cuenta/email">
<input type="hidden" name="email" value="atacante@ejemplo.com">
</form>
<script>
document.getElementById("f").submit();
</script>Tokens CSRF
Un valor secreto que la web del atacante no puede conocer y que tu servidor exige.
- Patrones:
- Synchronizer, double-submit
- En Angular:
- XSRF-TOKEN
El patrón synchronizer token guarda en la sesión del servidor un valor aleatorio y lo incrusta en cada formulario como campo oculto. Al recibir el formulario, el servidor compara ambos: la web del atacante puede enviar el formulario, pero no puede leer tu página para conocer el token.
El patrón double-submit cookie no guarda nada en el servidor: envía el token en una cookie y el frontend lo copia en una cabecera o un campo. El servidor comprueba que ambos coinciden. Funciona porque otra web no puede leer tus cookies. La versión recomendada firma el token y lo liga a la sesión, para que no baste con conseguir escribir una cookie en tu dominio.
Angular trae este segundo patrón de serie: HttpClient lee la cookie XSRF-TOKEN y la envía en la cabecera X-XSRF-TOKEN en las peticiones que modifican datos. Tu servidor solo tiene que crear la cookie y comprobar la cabecera.
Úsalo cuando
En cualquier aplicación con sesión en cookies que acepte formularios o peticiones que cambian datos. Es la capa que te protege aunque SameSite falle o no esté disponible.
Ten en cuenta
Usa la protección de tu framework en lugar de escribir la tuya; en Express, el paquete csurf está obsoleto, así que elige una alternativa mantenida. Y si tu web tiene XSS, el script inyectado puede leer el token: los tokens CSRF no sustituyen a las defensas contra XSS.
import { randomBytes } from "node:crypto";
// Al mostrar el formulario: un token aleatorio por sesión
app.get("/cuenta", (req, res) => {
req.session.csrf ??= randomBytes(32).toString("hex");
res.render("cuenta", { csrf: req.session.csrf });
// En la plantilla: <input type="hidden" name="_csrf" value="{{ csrf }}">
});
// Al recibirlo: sin token correcto, no se hace nada
app.post("/cuenta/email", (req, res) => {
if (req.body._csrf !== req.session.csrf) {
return res.status(403).send("Token CSRF no válido");
}
// ...cambiar el email
});Defensa en capas
Ninguna medida es perfecta, así que se combinan: cookies SameSite=Lax, tokens CSRF en los formularios, GET sin efectos y, como red de seguridad, rechazar en el servidor las peticiones cuyo Origin no sea el tuyo.
CORS y la política del mismo origen
CORS es probablemente el mecanismo de seguridad peor entendido de la web. La clave para entenderlo es saber primero qué restricción relaja.
La política del mismo origen
El JavaScript de una página no puede leer las respuestas de otro origen.
- Origen:
- Esquema + host + puerto
Un origen es la combinación de esquema, host y puerto: https://app.tienda.com y https://api.tienda.com son orígenes distintos, y también lo son http:// y https:// del mismo dominio. La política del mismo origen (same-origin policy) impide que el JavaScript de un origen lea las respuestas o el DOM de otro.
Lo que no impide es enviar: una página puede cargar imágenes, scripts y hojas de estilo de cualquier sitio, enviar formularios a cualquier dominio y lanzar fetch a otro origen. La restricción está en leer el resultado. Sin ella, cualquier web que visitaras podría leer tu bandeja de correo usando tu sesión.
Úsalo cuando
Siempre está activa: es el comportamiento por defecto del navegador. Lo que decides tú es cuándo relajarla con CORS.
Ten en cuenta
No confundas origen y sitio. El sitio es el esquema más el dominio registrable: app.tienda.com y api.tienda.com son el mismo sitio. Hay dominios compartidos que cuentan como sufijo público, así que ana.github.io y luis.github.io son sitios distintos.
https://app.tienda.com/perfil mismo origen
http://app.tienda.com otro origen (esquema)
https://app.tienda.com:8443 otro origen (puerto), mismo sitio
https://api.tienda.com otro origen (host), mismo sitio
https://tienda-falsa.com otro origen, otro sitioCORS
Cabeceras con las que un servidor permite que otros orígenes lean sus respuestas.
- Cabecera clave:
- Access-Control-Allow-Origin
- Lo aplica:
- El navegador
Con CORS (Cross-Origin Resource Sharing), el servidor indica en la respuesta qué orígenes pueden leerla con Access-Control-Allow-Origin, que vale * (cualquiera) o un origen concreto. El navegador lee esa cabecera y decide si entrega la respuesta al JavaScript o la oculta y muestra un error en la consola.
Por defecto, fetch no envía cookies a otros orígenes. Para enviarlas hace falta credentials: "include" y, para poder leer la respuesta, el servidor tiene que nombrar el origen exacto (el comodín * no vale con credenciales) y enviar Access-Control-Allow-Credentials: true.
Lo más importante: CORS relaja una restricción del navegador, no protege tu servidor. Una petición simple llega y se procesa aunque el navegador oculte después la respuesta, y curl o un script en un servidor ignoran CORS por completo.
Úsalo cuando
Cuando tu frontend y tu API viven en orígenes distintos, o cuando publicas una API que se llama desde las webs de otros. Si puedes servir ambos desde el mismo origen (con un proxy, por ejemplo), no necesitas CORS.
Ten en cuenta
No copies sin más la cabecera Origin de la petición en Access-Control-Allow-Origin con credenciales activadas: equivale a permitir que cualquier web lea los datos de tus usuarios. Usa una lista de orígenes permitidos y añade Vary: Origin.
import cors from "cors";
// Solo estos orígenes pueden leer las respuestas, con cookies
app.use("/api", cors({
origin: ["https://tienda.com", "https://admin.tienda.com"],
credentials: true,
}));
// Y en el frontend de https://tienda.com:
fetch("https://api.tienda.com/api/perfil", { credentials: "include" });Preflight (OPTIONS)
La pregunta previa que hace el navegador antes de una petición que no es simple.
- Método:
- OPTIONS
- Lleva cookies:
- Nunca
Las peticiones simples son las que una web ya podía hacer antes de CORS con un formulario: GET, HEAD o POST con cabeceras básicas y un Content-Type de formulario o texto. Todo lo demás, como un PUT, un DELETE, un POST con application/json o una cabecera Authorization, necesita permiso previo.
Para pedirlo, el navegador envía antes una petición OPTIONS, sin cookies, que describe el método y las cabeceras que quiere usar. Si la respuesta lo permite, envía la petición real; si no, la petición real nunca sale. Access-Control-Max-Age permite cachear ese permiso durante un tiempo, aunque cada navegador aplica su propio máximo.
Úsalo cuando
No lo activas tú: lo dispara el navegador. Lo que tienes que hacer es que tu servidor responda bien a OPTIONS en las rutas que se llaman desde otros orígenes.
Ten en cuenta
Un error muy común: el middleware de autenticación rechaza el OPTIONS con un 401 porque no lleva cookies ni token. El preflight siempre llega sin credenciales, así que debe responderse antes de comprobarlas.
OPTIONS /api/pedidos/42 HTTP/1.1
Origin: https://tienda.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://tienda.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600CORS no es una defensa
Si tu servidor confía en CORS para bloquear peticiones, está desprotegido: las peticiones simples llegan igual, y fuera del navegador nadie mira esas cabeceras. La autorización se comprueba en el servidor, en cada petición.
Interactivo
¿Llega, viaja la cookie y se puede leer?
Una página hace fetch a https://app.tienda.com/api/perfil, donde el usuario tiene la cookie de sesión. Cambia quién llama, cómo y qué responde el servidor.
https://ofertas-gratis.net
Respuesta del servidor
(sin Access-Control-Allow-Origin)¿Hay preflight? No
No: GET y POST de formulario son peticiones simples, las mismas que puede lanzar un
<form>o una<img>, así que salen directamente.¿Llega al servidor? Sí
Sí, y el servidor la procesa, pueda o no leer la respuesta la página.
¿Viaja la cookie? Sí
Sí:
SameSite=Nonepermite enviarla desde cualquier sitio. Ojo: Safari y Firefox bloquean o aíslan por defecto las cookies de terceros, así que allí podría no viajar.¿Puede leer la respuesta el JavaScript? No
No: el servidor no envía
Access-Control-Allow-Origin. La respuesta llegó al navegador, pero este se la oculta al JavaScript.
Peligro de CSRF: otra web ha hecho llegar un POST con la cookie de sesión del usuario. Si tu servidor solo mira la cookie, ejecutará la acción. Protégete con SameSite=Lax o Strict y con un token CSRF.
Simplificado: todo va por HTTPS y, cuando envía Access-Control-Allow-Origin, el servidor también permite la cabecera Content-Type en el preflight.
Interactivo
Los tres valores de SameSite
Cuándo viaja la cookie según quién inicia la petición.
| Característica | Strict | Lax | None; Secure |
|---|---|---|---|
| Desde tu propio sitio | Sí | Sí | Sí |
| Enlace desde otro sitio | No | Sí | Sí |
| Formulario POST desde otro sitio | No | No | Sí |
| fetch, iframe o imagen desde otro sitio | No | No | Sí, si el navegador no bloquea las cookies de terceros |
| Uso típico | Paneles de administración, acciones críticas | La cookie de sesión de casi cualquier web | Widgets e iframes incrustados en otras webs |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Un pentest demuestra que un script inyectado en los comentarios puede leer document.cookie y enviar la cookie de sesión a un servidor externo.
Para recordar
- Una cookie de sesión debería llevar
Secure,HttpOnlyySameSite=LaxoStrict. - Origen es esquema + host + puerto; sitio es el dominio registrable.
SameSitepiensa en sitios, CORS en orígenes. - El CSRF aprovecha que el navegador adjunta las cookies solo; se frena con
SameSite, tokens CSRF y comprobandoOrigin. - CORS relaja la política del mismo origen para permitir leer respuestas; no impide que las peticiones lleguen a tu servidor.
- Con credenciales, CORS exige el origen exacto y
Access-Control-Allow-Credentials: true; el preflight nunca lleva cookies.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.En Chrome, ¿qué ocurre con una cookie de sesión que se crea sin el atributo
SameSite?2.Tu API responde con
Access-Control-Allow-Origin: *y el frontend llama concredentials: "include". ¿Qué pasa?3.¿Por qué una buena configuración de CORS no protege contra el CSRF?
4.La página
https://blog.tienda.comhacefetchahttps://app.tienda.comconcredentials: "include", y la cookie esSameSite=Strict. ¿Viaja la cookie?5.En el patrón double-submit cookie, ¿por qué la web del atacante no puede falsificar la petición?