● CWE-352 · OWASP A01:2021

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.example a app.example sigue llevando la cookie de sesión de app.example salvo que el atributo SameSite de la cookie lo impida.
Atributo SameSite de la cookie
Strict retiene la cookie en toda petición entre sitios, Lax solo la envía en navegaciones GET de nivel superior y None la envía siempre (y exige Secure). Chromium trata las cookies sin el atributo como Lax; 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. CSRFProtect de Flask-WTF lo emite con csrf_token() y comprueba el campo csrf_token o la cabecera X-CSRFToken.
Fetch Metadata (Sec-Fetch-Site)
Los navegadores modernos envían Sec-Fetch-Site: cross-site, same-site, same-origin o none en cada petición, y las páginas no pueden falsificarlo. Rechazar los métodos inseguros que no sean same-origin bloquea 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, y SameSite=Lax sigue enviando la cookie en las navegaciones GET de nivel superior. Leer request.values acepta la query string además del cuerpo del formulario.

Flujo de Ataque Paso a Paso

Paso 1

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.

Paso 2

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.

Paso 3

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.

Paso 4

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

IMPLEMENTACIÓN VULNERABLE
# 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"}
PARCHE SEGURO Y ROBUSTO
# 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

Fuentes

← Ver el directorio completo de seguridad Todas las guías de vulnerabilidades →