Saltar al contenido
Nuevo

DeepSeek V4.1 Flash gratis por 7 días: 10M tokens al día tras cualquier recarga. Hasta el 30/11.

Cómo funciona

Qué es CSRF y cómo ayudan las cookies SameSite

CSRF usa las cookies de tu propio navegador en tu contra. Revisa las defensas de la más fuerte a la más débil y prueba SameSite con un laboratorio Flask.

Intermedio13 min de lecturaPor NotHoly

La falsificación de petición entre sitios (CSRF, por sus siglas en inglés) permite que una página de un sitio haga que tu navegador envíe una petición que cambia datos a otro sitio donde tienes sesión iniciada. El navegador adjunta la cookie de sesión por su cuenta, así que el otro sitio trata la petición como si fuera tuya. Las defensas son un secreto por sesión que el atacante no puede leer, el atributo SameSite de la cookie, la comprobación de Origin y la regla de que las peticiones GET nunca cambian estado. Este artículo cubre cada una y luego ejecuta un pequeño laboratorio con Flask para que veas la cookie en movimiento.

Qué es CSRF

Una sesión web normalmente vive en una cookie. Después de iniciar sesión en bank.example, tu navegador guarda una cookie para ese host y la envía en cada petición a ese host. El servidor lee la cookie, encuentra tu sesión y ejecuta la acción.

El navegador no verifica desde dónde se originó la petición. Si una página en evil.example tiene un formulario que envía un POST a https://bank.example/transfer, el navegador envía ese POST con la cookie de bank.example adjunta. El banco ve una sesión válida y hace la transferencia. El atacante nunca ve tu cookie ni lee la respuesta. El ataque solo necesita que el servidor ejecute la acción.

La CWE-352 describe la debilidad así: la aplicación ejecuta una acción que cambia estado para un usuario sin comprobar que el usuario quiso hacer esa petición. La solución es que el servidor pueda distinguir una petición hecha por el usuario de una falsificada por una página de terceros.

Por qué importa

Cualquier aplicación que guarde sesiones en cookies y cambie datos con una petición está expuesta. Las acciones que más importan son las que cambian credenciales o configuración: cambiar la contraseña, cambiar el correo, crear una clave de API, agregar un medio de pago, crear una regla de reenvío. Un cambio de correo falsificado suele ser el primer paso de una toma de cuenta, porque el atacante puede restablecer la contraseña a través de la dirección nueva.

Las sesiones con cookies son el valor por defecto en muchos frameworks, y el navegador envía la cookie aunque el desarrollador no lo haya pensado. Por eso CSRF sigue apareciendo en las listas de OWASP y de CWE.

Cómo funciona el ataque, paso a paso

  1. La víctima inicia sesión en bank.example. El navegador guarda la cookie de sesión para ese host.
  2. La víctima abre otra página en el mismo navegador, sin cerrar sesión. La página es del atacante.
  3. La página tiene un formulario que envía un POST a https://bank.example/transfer con valores que eligió el atacante. Puede enviarse al cargar, así que la víctima no necesita hacer clic en nada.
  4. El navegador envía el POST e incluye la cookie de bank.example.
  5. El servidor valida la cookie, ve a un usuario con sesión iniciada y ejecuta la transferencia.

Fíjate en lo que el atacante no necesitó. No robó la cookie, no adivinó una contraseña ni leyó un solo byte de la respuesta del banco. Quien hizo el trabajo fue el navegador.

Por qué GET no debe cambiar estado

Una petición GET puede dispararse sin ningún formulario. Una etiqueta <img src="https://bank.example/delete?id=7"> hace que el navegador envíe un GET en cuanto se renderiza la página. La precarga, las vistas previas de enlaces y los rastreadores también envían GET. Si una ruta GET cambia datos, CSRF no necesita formulario.

La regla es simple: GET y HEAD leen datos y nunca los cambian. Los cambios van por POST, PUT, PATCH o DELETE. La regla no es una defensa por sí sola, porque un POST también puede falsificarse, pero todas las demás defensas funcionan mejor cuando la regla se cumple.

Las defensas, de la más fuerte a la más débil

1. Tokens sincronizados

El servidor crea un token aleatorio para cada sesión, lo pone en cada formulario como campo oculto y lo verifica en cada petición que cambia estado. Una petición sin el token correcto se rechaza.

El token funciona porque la página del atacante no puede leerlo. La política de mismo origen impide que evil.example lea el HTML que devolvió bank.example, así que el atacante no puede copiar el campo oculto a su formulario falsificado. La petición falsificada llega con la cookie y sin el token.

El token debe ser impredecible (usa un generador aleatorio criptográfico), estar ligado a la sesión del usuario y comprobarse en todo método no seguro, incluidos los endpoints que llama tu JavaScript. La mayoría de los frameworks ya traen esta protección. La hoja de referencia de OWASP pone primero las defensas basadas en token por esa razón.

Las cookies de double-submit son una variante más débil. El token va en una cookie y en un campo de la petición, y el servidor compara ambos. Un atacante que pueda definir cookies en tu dominio, por ejemplo desde un subdominio hermano, puede vencerlo. Prefiere el token sincronizado cuando puedas guardar estado en el servidor.

SameSite le indica al navegador cuándo adjuntar una cookie a una petición que empieza en otro sitio. El atributo tiene tres valores.

  • Strict envía la cookie solo en peticiones que empiezan en el mismo sitio. Un enlace desde un correo hacia tu aplicación llega sin la cookie, así que el usuario parece deslogueado en la primera visita.
  • Lax envía la cookie en peticiones del mismo sitio y en navegaciones GET de nivel superior desde otro sitio, como al seguir un enlace. No la envía en POST entre sitios, ni en peticiones que cargan imágenes, marcos y scripts de otro sitio. Lax bloquea el ataque clásico con formulario.
  • None envía la cookie también en peticiones entre sitios. Exige el atributo Secure, así que la cookie solo viaja por HTTPS. Usa None solo cuando un flujo real entre sitios necesite la cookie, como un widget de terceros incrustado.

MDN documenta el atributo y sus valores en la referencia de Set-Cookie. El borrador RFC 6265bis construye "sitio" sobre el dominio registrable (el sufijo público más la etiqueta a su izquierda, es decir, site.example para www.site.example). El número de puerto no cuenta. Los subdominios cuentan como el mismo sitio. Esto importa en el laboratorio, porque localhost y 127.0.0.1 son hosts distintos y, por lo tanto, sitios distintos.

SameSite es fuerte, pero no completo. Un subdominio hermano como forum.bank.example es del mismo sitio que bank.example, así que Lax no detiene un ataque lanzado desde ahí. Los navegadores antiguos que ignoran el atributo envían la cookie en todas partes. Y Lax sigue enviando la cookie en navegaciones GET de nivel superior, por eso la regla del GET sigue vigente. Trata SameSite como una capa.

3. Comprobaciones de Origin y Referer

Los navegadores envían la cabecera Origin en peticiones POST y en la mayoría de las peticiones entre sitios. La cabecera contiene el esquema, el host y el puerto de la página que inició la petición, así que el servidor puede compararla con su propio origen. Sec-Fetch-Site da una señal parecida, con valores como same-origin, same-site y cross-site.

La comprobación es simple. Rechaza las peticiones que cambian estado cuando Origin está presente y no coincide con tu origen. Si Origin falta, usa Referer. Si faltan las dos en una petición que cambia estado, recházala. Interpreta la cabecera como URL y compara el origen completo. Una comparación por subcadena falla: Origin: https://bank.example.evil.test contiene tu dominio, pero pertenece a otra persona.

Las comprobaciones de origen detectan peticiones evidentes entre sitios, pero dependen de cabeceras que algunos clientes omiten y que la configuración de privacidad puede quitar. Úsalas como filtro y mantén un token para los casos en que las cabeceras no aparecen.

4. Nunca cambiar estado con GET

Esta es una regla, no un control que se pruebe con una sola petición. Consérvala porque hace que todas las demás defensas cubran más terreno. Una ruta que cambia estado solo con POST le da al atacante un formulario que falsificar, no una etiqueta de imagen que plantar.

Necesitas Python 3.12 y tres ventanas de terminal. La ejecución de este artículo usó Python 3.12.10 en Windows 11 con Git Bash. El laboratorio tiene una aplicación Flask en 127.0.0.1:5000 y una página estática simple en localhost:8001. El segundo origen es lo que hace que la petición sea entre sitios.

Preparar el entorno

Crea un entorno virtual e instala Flask. En Linux y macOS, actívalo con . .venv/bin/activate. En Windows con Git Bash, el script de activación está en .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

En esta ejecución, las versiones instaladas fueron Flask 3.1.3 y Werkzeug 3.1.9.

Escribir la aplicación

Guárdala como app.py. La ruta /login establece una cookie de sesión con SameSite=Lax y HttpOnly. La ruta /transfer no cambia nada. Solo imprime si la cookie llegó, junto con dos cabeceras que ayudan a leer el resultado.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

La aplicación todavía no valida nada. Eso es intencional. Quieres ver qué envía el navegador antes de agregar una defensa.

En la primera terminal:

bash
flask --app app run --host 127.0.0.1 --port 5000

En una segunda terminal, pide la ruta de login y lee la cabecera:

bash
curl -i http://127.0.0.1:5000/login

Salida de esta ejecución, sin la línea Date:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

La línea Set-Cookie es lo importante. HttpOnly mantiene la cookie lejos del JavaScript de la página. No impide que el navegador envíe la cookie, así que HttpOnly no es una defensa contra CSRF. SameSite=Lax es el atributo que cambia lo que el navegador envía entre sitios.

Simular lo que ve el servidor

Antes de involucrar a un navegador, envía las peticiones tú mismo para saber qué registra el servidor:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

Respuestas de esta ejecución, una línea por comando:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

Las líneas que imprimió la terminal de Flask para esas tres peticiones:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

curl no aplica SameSite. Envía lo que le pases con -b. Por lo tanto, estas líneas prueban que la ruta lee la cookie y las cabeceras. No prueban lo que envía un navegador. Ese es el siguiente paso.

Servir un formulario desde un segundo origen

Crea una carpeta llamada attacker y guarda este contenido en attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

En una tercera terminal, inicia un servidor estático desde la carpeta que contiene attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

En esta ejecución, una petición al servidor estático devolvió HTTP 200. La página se carga entonces en http://localhost:8001/attacker/. Fíjate en el nombre del host. localhost y 127.0.0.1 son hosts distintos, así que el formulario servido desde localhost:8001 envía el POST a un sitio distinto del host de la cookie. Esa es la configuración entre sitios que necesitas.

Comparar SameSite=Lax con SameSite=None

Primero abre http://127.0.0.1:5000/login en tu navegador, para que la cookie quede guardada para 127.0.0.1 en ese perfil. Luego abre http://localhost:8001/attacker/ en el mismo navegador y haz clic en Send transfer. Lee la terminal de Flask.

Con SameSite=Lax, un POST de formulario entre sitios no debería llevar la cookie. La línea de registro esperada es cookie_arrived=False. El navegador omite la cookie porque Lax no la envía en POST entre sitios. El paso en el navegador no se ejecutó para este artículo, así que comprueba tú mismo la línea de registro contra esta expectativa.

Ahora reinicia la aplicación con None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

Lee la cabecera en el nuevo servidor:

bash
curl -i http://127.0.0.1:5000/login

Salida de esta ejecución:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

SameSite=None por sí solo no basta. Los navegadores rechazan una cookie None sin Secure, y Secure exige HTTPS. El laboratorio sirve HTTP simple en 127.0.0.1, y que un navegador guarde una cookie Secure desde ese origen depende del navegador. Abre la cookie en las herramientas de desarrollo de tu navegador y comprueba si existe. Si no se guardó, el registro del servidor muestra cookie_arrived=False, pero la causa es la regla de Secure, no SameSite. Si se guardó, vuelve a enviar el formulario. Una línea cookie_arrived=True con None significa que el navegador envió la cookie en un POST entre sitios. Ese es el comportamiento que vuelve riesgoso a None para cookies de sesión, y por eso debes usar Lax o Strict, salvo que un flujo real entre sitios necesite None.

Detén cada servidor con Ctrl+C cuando termines.

Lo que el laboratorio no demuestra

El laboratorio muestra cómo las cookies y SameSite cambian lo que recibe el servidor. No agrega una defensa. La ruta /transfer sigue aceptando cualquier POST que lleve la cookie, así que una cookie SameSite=None dejaría que un formulario falsificado tuviera éxito. Para cerrar esa brecha, agrega un token sincronizado al formulario y compruébalo en la ruta. El laboratorio se detiene aquí para que primero veas el atributo por sí solo.

Errores comunes

  • Confiar en CORS para detener CSRF. CORS controla si una página puede leer una respuesta. No impide que el navegador envíe un POST simple entre sitios. La petición igual llega al servidor.
  • Pensar que HttpOnly detiene CSRF. HttpOnly oculta la cookie del JavaScript. El navegador sigue adjuntándola a las peticiones falsificadas.
  • Comprobar el token solo en algunas rutas. Los endpoints que llaman las peticiones AJAX suelen saltarse la comprobación. El atacante busca justo la ruta que la omite.
  • Poner el token en la URL. Las URL aparecen en los registros del servidor, en las cabeceras Referer y en el historial del navegador.
  • Hacer que las rutas GET cambien datos. Entonces basta una simple etiqueta de imagen para atacar, y SameSite=Lax deja de proteger la ruta.
  • Usar None sin Secure. Los navegadores lo rechazan. Si cambias el atributo para arreglar un flujo roto, podrías abrir una ruta entre sitios que no querías abrir.
  • Comparar Origin por subcadena. evil-bank.example y bank.example.evil.test contienen bank.example. Compara orígenes completos.
  • Confiar solo en SameSite. Es fuerte frente a sitios no relacionados, pero los subdominios hermanos son del mismo sitio, y los navegadores antiguos pueden ignorar el atributo.

Cómo seguir aprendiendo

Lee la hoja de referencia de OWASP de principio a fin. Cubre los patrones de token, las comprobaciones de cabeceras y los casos que cada uno deja pasar. Lee el borrador RFC 6265bis para las reglas exactas de SameSite, incluido el valor por defecto cuando falta el atributo y cómo se tratan las navegaciones de nivel superior. La página Set-Cookie de MDN es una referencia más rápida para los valores del atributo.

En tu propia aplicación, usa la protección contra CSRF que ofrece tu framework y pruébala. Escribe una prueba que envíe un POST a una ruta que cambia estado sin token y que espere un rechazo. Escribe una segunda prueba que envíe un POST con un token de otra sesión y que también espere un rechazo. Si usas Flask, revisa la extensión CSRFProtect de Flask-WTF. Revisa las advertencias de cookies en las herramientas de desarrollo del navegador, porque Chrome informa los problemas de SameSite en su panel Issues.

Para la capa de transporte debajo de estas cookies, lee qué hacen HTTPS y TLS. El atributo Secure solo tiene sentido cuando la conexión está cifrada. El siguiente artículo de esta serie, cómo escribir tu primer informe de seguridad, muestra cómo registrar un hallazgo como este para el equipo dueño del código.

Fuentes

Fuentes

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

Más en Seguridad web y de APIs

Seguridad web y de APIs

OWASP Top 10 explicado para desarrolladores

Qué es el OWASP Top 10, qué significa cada categoría de 2025, cómo defenderse y cómo mapear retos de Juice Shop.

Principiante16 min de lectura

Seguridad web y de APIs

SQL Injection explicada con OWASP Juice Shop

Cómo la SQL Injection cambia el sentido de una consulta, por qué las consultas parametrizadas la corrigen y un laboratorio con Juice Shop y sqlite3.

Intermedio11 min de lectura