Saltar al contenido
Framework Ecosystems LabsUn contrato, muchos ecosistemas, la misma prueba.

Clase 072 — CSRF#

⬅️ 071 · 📚 Parte 5 · 🎓 Clases · 073 ➡️ Parte 5 — Identidad y seguridad · Nivel 🟡 intermedio · Pista backendClase construida — 4 implementaciones verificadas contra contrato.json.

🎯 Objetivo#

Entender el ataque antes de activar la defensa. El Cross-Site Request Forgery explota una decisión de diseño del navegador: la cookie viaja sola — el navegador la adjunta a toda petición hacia su dominio, la inicie quien la inicie [rfc6265].

🧩 La situación: el ataque, en concreto#

  1. La víctima inicia sesión en banco.example. Cookie en el navegador.
  2. Visita atacante.example, donde una página invisible envía POST banco.example/transferir.
  3. El navegador adjunta la cookie de banco.example — es su trabajo.
  4. Para el servidor, la petición es indistinguible de una legítima: cookie válida, sesión válida, usuario autenticado.

El atacante no robó nada — ni cookie, ni contraseña. Usó a la víctima como proxy confuso: hizo que su navegador, ya autenticado, trabajara para él [hoffman-web-application-security].

📖 La defensa#

Dos capas, y las clases anteriores ya pusieron la primera:

🧮 El contrato#

Petición Respuesta Qué mide
POST /entrar 200 · cookie + testigo en el cuerpo el reparto: cookie e información van por canales distintos
GET /saldo sin sesión 401 quién eres, primero
POST /transferir solo con la cookie 403 la petición del atacante, literal
con un testigo inventado 403 adivinar tampoco
con el testigo entregado 200 · saldo: 90 la legítima pasa
GET /saldo saldo: 90 los dos 403 nunca movieron dinero

El último caso es el que separa este contrato de una prueba decorativa: no basta con que los ataques reciban 403 — hay que comprobar que el estado no cambió. Un 403 emitido después de transferir también pasaría los casos anteriores.

<!-- generado: fichas -->

📖 Las palabras que esta clase define#

Si alguna de estas no te dice nada todavía, esta es la clase donde se aprende. Las definiciones viven en el glosario, que reúne las del programa entero.

Palabra Qué significa
Seguro (método) Un método que no cambia el estado del servidor. GET y HEAD lo son. Un GET que transfiera dinero es indefendible: bastaría una etiqueta <img> en cualquier página para dispararlo.
CSRF (Falsificación de petición entre sitios) El ataque en que una página del atacante provoca una petición a tu sitio y el navegador adjunta la cookie — es su trabajo. Se corta con un testigo que el atacante no puede leer ni adivinar, y con SameSite.
Testigo sincronizado (Token CSRF) Un valor aleatorio que vive en la sesión y viaja en el cuerpo o en una cabecera —nunca en una cookie sola, que el navegador también adjuntaría—. La página del atacante no puede leerlo porque no puede leer respuestas de otro origen.

🧰 Las piezas de esta clase, una por una#

Antes del código: qué es cada framework, qué versión se está usando y qué hace falta para ejecutarlo. Todo lo de esta sección sale de los archivos reales del repositorio —el catálogo, la receta de arranque y el manifiesto de dependencias de cada ecosistema—, así que no puede quedarse desactualizado sin que la validación lo detecte.

Framework Qué es Desde Licencia Quién lo mantiene
Express framework web de Node.js (JavaScript) 2010 MIT OpenJS Foundation
FastAPI framework web de Python (Python) 2018 MIT proyecto independiente
Spring Boot framework de aplicación de JVM (Java) 2014 Apache-2.0 Broadcom/VMware y colaboradores
ASP.NET Core framework web de .NET (C#) 2016 MIT Microsoft y .NET Foundation

🔧 Express#

Definió el modelo de middleware encadenado que copiaron casi todos los frameworks de Node.js. Minimalista no significa biblioteca: posee el bucle de peticiones.

Preparar sus dependencias, dentro de su directorio:

pnpm install --silent --ignore-scripts

Arrancarla suelta, sin el verificador:

PORT=3000 node server.mjs

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
package.json manifiesto de Node.js: nombre, tipo de módulo y dependencias con su rango de versión
pnpm-lock.yaml archivo de bloqueo: la versión exacta de cada dependencia y de sus dependencias
pnpm-workspace.yaml raíz de instalación propia, y la prohibición de ejecutar scripts al instalar
server.mjs código JavaScript (módulo ES)

🔧 FastAPI#

Deriva validación, serialización y documentación OpenAPI de las anotaciones de tipo. Demostró que el tipado opcional de Python podía ser infraestructura, no adorno.

Arrancarla suelta, sin el verificador:

PORT=3000 python -m uvicorn main:app --host 127.0.0.1 --port 3000

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
main.py código Python
requirements.txt dependencias de Python, una por línea, con versión fijada

🔧 Spring Boot#

Autoconfiguración y servidor incrustado sobre Spring. Convirtió un framework famoso por su configuración XML en uno de arranque inmediato.

Preparar sus dependencias, dentro de su directorio:

mvn -q -B package -DskipTests

Arrancarla suelta, sin el verificador:

PORT=3000 java -jar target/clase-072-1.0.0.jar --server.port=3000

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
pom.xml manifiesto de Maven: el proyecto, su Java, sus dependencias y cómo se empaqueta
src/main/java/labs/Aplicacion.java código Java

🔧 ASP.NET Core#

Reescritura multiplataforma y de código abierto de la pila web de Microsoft. Sus API mínimas trajeron el estilo de los microframeworks al ecosistema .NET.

Preparar sus dependencias, dentro de su directorio:

dotnet build -c Release --nologo -v quiet

Arrancarla suelta, sin el verificador:

PORT=3000 dotnet run -c Release --no-build --urls http://127.0.0.1:3000

Qué hay dentro de su directorio:

Archivo Qué es
Clase072.csproj proyecto de .NET: el marco de destino y las dependencias
Program.cs código C#
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca

Si alguna cadena de herramientas no está en tu máquina, node scripts/doctor.mjs dice cuál falta y con qué comando se instala. No hace falta tenerlas todas: el verificador ejecuta lo que encuentra y declara lo que omitió.

<!-- fin generado: fichas -->

🌐 Las implementaciones — el código a la vista#

El reparto más nítido de la parte: en dos frameworks la protección no se escribe, se usa; en los otros dos se compone a mano. Y la diferencia no es de gusto — es de cuántas líneas tuyas se interponen entre un atacante y el dinero.

Spring Boot · spring-boot/…/Aplicacion.java — no se escribe, se activa#

                .csrf(csrf -> csrf.ignoringRequestMatchers("/entrar"))

Esa línea es toda la defensa. El CsrfFilter de Spring Security guarda el testigo en la sesión, exige X-CSRF-TOKEN en todo lo que muta y responde 403 él solo: el código de las rutas no se entera de que existe un ataque llamado CSRF. Lo único que escribe la aplicación es la entrega del testigo:

        CsrfToken testigo = (CsrfToken) peticion.getAttribute("_csrf");
        return ResponseEntity.ok(Map.of("usuario", usuario, "csrf", testigo.getToken()));

Fíjate también en lo que la línea excluye: /entrar queda fuera porque es donde el cliente consigue su primer testigo — no puede exigirse uno a quien todavía no tiene ninguno. Y es la primera clase de la parte que no apaga el CSRF; las anteriores lo desactivaban porque eran API sin cookies, y ahí no hay nada que proteger.

ASP.NET Core · aspnet-core/Program.cs — el par firmado#

constructor.Services.AddAntiforgery(opciones =>
{
    opciones.HeaderName = "x-csrf-token";
});
    var testigos = antiforgery.GetAndStoreTokens(contexto);
    return Results.Json(new { usuario, csrf = testigos.RequestToken });
        await antiforgery.ValidateRequestAsync(contexto);
    }
    catch (AntiforgeryValidationException)
    {
        return Results.Json(new { error = "testigo-invalido" }, statusCode: 403);

Emite un par: una cookie firmada que se queda en el navegador y un testigo que el cliente debe repetir en el encabezado. Es el patrón double-submit con firma — la variante sin estado del testigo, que no necesita guardar nada en el servidor. La página del atacante tiene la cookie (el navegador la adjunta sola) pero no puede leer el testigo, y sin los dos no pasa.

La única diferencia práctica con Spring es que aquí la validación se invoca: ValidateRequestAsync está en el cuerpo de la ruta, no en un filtro anterior.

Express · express/server.mjs — quince líneas, y por qué#

    peticion.session.csrf = crypto.randomBytes(24).toString("base64url");
    respuesta.json({ usuario, csrf: peticion.session.csrf });
function conTestigo(peticion, respuesta, siguiente) {
  const recibido = peticion.get("x-csrf-token") ?? "";
  const esperado = peticion.session.csrf ?? "";
  const iguales =
    recibido.length === esperado.length &&
    crypto.timingSafeEqual(Buffer.from(recibido), Buffer.from(esperado));
  if (!esperado || !iguales) {
    return respuesta.status(403).json({ error: "testigo-invalido" });
  }
  siguiente();
}
app.post("/transferir", conSesion, conTestigo, (peticion, respuesta) => {

El testigo vive en la sesión y viaja en el cuerpo de la respuesta, nunca en una cookie sola: una cookie el navegador también la adjuntaría sola, y entonces el atacante la tendría igual que tiene la de sesión.

timingSafeEqual es la comparación en tiempo constante de la clase 068, tercera aparición. Y la comprobación de longitud antes es obligatoria, no cosmética: timingSafeEqual lanza si los dos búferes miden distinto.

El dato que importa de esta implementación no está en el código: el middleware histórico de Express para esto, csurf, está retirado. Que el paquete de referencia para un ataque del top de OWASP quedara sin mantenimiento es información sobre el ecosistema, no una anécdota — y es exactamente la pregunta que el módulo 11 enseña a hacer antes de elegir.

FastAPI · fastapi/main.py — sobre la sesión de la 066#

    testigo = secrets.token_urlsafe(24)
    sesiones[identificador] = {"usuario": credenciales.usuario, "csrf": testigo}
    respuesta = JSONResponse({"usuario": credenciales.usuario, "csrf": testigo})
    if not x_csrf_token or not secrets.compare_digest(actual["csrf"], x_csrf_token):
        return JSONResponse({"error": "testigo-invalido"}, status_code=403)

Nunca lo tuvo, y se compone sobre la sesión que la clase 066 ya había tenido que construir a mano. compare_digest acepta cadenas de distinta longitud sin lanzar, así que aquí no hace falta la comprobación previa de Express — una diferencia pequeña de la biblioteca estándar que ahorra un fallo real.

Lo que los cuatro dejan sin testigo#

app.get("/saldo", conSesion, (peticion, respuesta) => {

GET no lleva testigo en ninguna de las cuatro implementaciones, a propósito: la defensa protege las escrituras. Y eso solo es seguro si el GET cumple lo que la clase 014 exige de él — no mutar nada. Un GET que transfiriera dinero sería indefendible: el atacante no necesitaría un formulario, le bastaría una etiqueta <img>.

📊 Comparación#

Framework La pieza Dónde ocurre el 403 Patrón
Spring Boot CsrfFilter, de serie y activo por omisión en el filtro, antes de tu código sincronizado en sesión
ASP.NET Core Antiforgery, de serie donde tú llamas a validar double-submit firmado
Express compuesta (csurf retirado) en tu middleware sincronizado en sesión
FastAPI compuesta en tu handler sincronizado en sesión

Spring es el único donde la defensa está activa por omisión — tan por omisión que la mayoría de los tutoriales de API empiezan por apagarla (csrf.disable(), clases 070 y 071 incluidas). Esta clase es el contexto de esa línea: se apaga cuando no hay cookies de sesión que proteger, y solo entonces.

⚠️ Errores frecuentes#

✅ Verificación#

node scripts/run-class.mjs 072

Los casos están en contrato.json. El verificador ejecuta las implementaciones que encuentre y declara las que omitió.

🧪 Reto de transferencia#

Cambia la defensa de Express al patrón double-submit firmado de ASP.NET (el testigo en una cookie propia, firmada, más el encabezado) y decide qué casos del contrato cambian. Después responde con código: ¿por qué el double-submit necesita la firma? — plantar cookies desde un subdominio es la respuesta.

🔗 Enlaces#

Fuentes#