Cross-Site Request Forgery (CSRF, CWE-352): cómo funciona y cómo evitarlo
Una página de otro sitio puede hacer que el navegador de un usuario con sesión iniciada envíe a tu app una petición que cambia datos, y el navegador adjunta la cookie de sesión automáticamente, así que una ruta que solo comprueba la cookie ejecuta la acción. Cómo lo impiden los tokens sincronizadores, las cookies SameSite y las comprobaciones de Sec-Fetch-Site, con un ejemplo en Flask y Flask-WTF.
Explicación en Lenguaje Sencillo (ELI5)
Tu oficina tiene una sala de correo que ejecuta cualquier solicitud firmada que quede en tu bandeja de salida, y confía en ellas porque están en tu bandeja, a la que solo tú llegas. Un visitante te pide que dejes un sobre cerrado en la bandeja al salir. Dentro hay una solicitud que dice 'cambien la dirección de entrega de todos mis paquetes'. La sala de correo la ve en tu bandeja, supone que la escribiste tú y la ejecuta. El CSRF funciona igual: el navegador pone tu cookie de sesión en cada petición a tu banco, incluida una que inició otro sitio web. La solución es un sello secreto que solo las páginas de tu propia oficina pueden poner en la solicitud, para que la sala rechace las que no lo llevan.
Conceptos Clave y Términos
- Credenciales ambientales
- Los navegadores adjuntan cookies y autenticación HTTP a las peticiones hacia un sitio sin importar qué página las inició. Una petición de
evil.exampleaapp.examplesigue llevando la cookie de sesión deapp.examplesalvo que el atributoSameSitede la cookie lo impida. - Atributo SameSite de la cookie
Strictretiene la cookie en toda petición entre sitios,Laxsolo la envía en navegaciones GET de nivel superior yNonela envía siempre (y exigeSecure). Chromium trata las cookies sin el atributo comoLax; Firefox y Safari no, y 'mismo sitio' incluye también los subdominios hermanos.- Token sincronizador
- Un valor aleatorio ligado a la sesión que el servidor pone en cada formulario o página y exige en cada petición insegura. Otro sitio no puede leerlo, así que no puede falsificar una petición válida.
CSRFProtectde Flask-WTF lo emite concsrf_token()y comprueba el campocsrf_tokeno la cabeceraX-CSRFToken. - Fetch Metadata (Sec-Fetch-Site)
- Los navegadores modernos envían
Sec-Fetch-Site: cross-site,same-site,same-originononeen cada petición, y las páginas no pueden falsificarlo. Rechazar los métodos inseguros que no seansame-originbloquea el CSRF con una sola comprobación en el servidor. - Cambio de estado en GET
- Una ruta que cambia datos con GET puede dispararse con una etiqueta
<img>o un enlace, ySameSite=Laxsigue enviando la cookie en las navegaciones GET de nivel superior. Leerrequest.valuesacepta la query string además del cuerpo del formulario.
Flujo de Ataque Paso a Paso
La víctima tiene la sesión iniciada
Tiene una cookie de sesión de app.example, enviada con SameSite=None o sin el atributo en un navegador que no usa Lax por defecto.
Abre una página que controla el atacante
Contiene un formulario oculto que envía a https://app.example/account/email y se envía solo al cargar. Un formulario que envía el valor de prueba inofensivo email=test@example.com ilustra el caso.
El navegador envía la petición con la cookie
La petición lleva la sesión de la víctima, y el servidor no ve nada que le indique que el formulario estaba en otro sitio.
El servidor modifica la cuenta
El correo pasa a ser el del atacante, que luego restablece la contraseña y se queda con la cuenta. En 2008 Zeller y Felten mostraron fallos de CSRF de este tipo en ING Direct, YouTube y The New York Times.
Código Fuente: Vulnerable vs. Seguro
# app.py: la cookie de sesión es la única prueba de que el usuario lo quiso
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET también funciona, y request.values lee además la query string
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
# app.py: token, cookie SameSite y Fetch Metadata juntos
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# cada POST necesita csrf_token; los formularios muestran {{ csrf_token() }}, fetch() envía X-CSRFToken
csrf = CSRFProtect(app)
# las escrituras iniciadas por otro sitio se rechazan antes de ejecutar la ruta
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
abort(403)
# solo POST, datos del cuerpo del formulario
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
Lista de Verificación de Seguridad para Ingeniería
- Cambia el estado solo con POST, PUT, PATCH o DELETE y lee los datos del formulario del cuerpo (
request.form), nunca derequest.valuesni de la query string. - Activa el middleware de CSRF del framework para toda la app (
CSRFProtect,CsrfViewMiddlewarede Django,csrf()de Spring Security) e incluye el token en cada formulario y petición AJAX. - Configura las cookies de sesión como
Secure,HttpOnlyySameSite=LaxoStrict; usaSameSite=Nonesolo en cookies que deban funcionar dentro de iframes de otros sitios. - Rechaza las peticiones inseguras cuya cabecera
Sec-Fetch-Siteseacross-siteosame-site. - Añade una prueba en CI que envíe a cada ruta que cambia estado una petición sin el token y espere 400 o 403.