CodeGym /Cursos /Spring Security /CSRF en upload: cabecera y parámetro

CSRF en upload: cabecera y parámetro

Spring Security
Nivel 13, Lección 2
Disponible

1. CSRF al cargar un archivo

Cuando un desarrollador oye hablar por primera vez de CSRF, suele pensar en algún PATCH /profile o DELETE /drafts/42. El avatar parece «no tan peligroso»: ¿cambiar una imagen, a quién le duele? Pero el upload es también una operación que cambia el estado: el servidor recibe datos, los guarda, actualiza el enlace en el perfil y (idealmente) además limpia el archivo antiguo. No es una lectura, es una modificación de estado.

Además, el upload tiene una característica desagradable: a menudo es una solicitud pesada. Si permites que el navegador «por error» o bajo ataque envíe cargas de varios megabytes sin un control CSRF correcto, le estás regalando al atacante una herramienta muy cómoda para sobrecargar tu servicio. Y sí, el atacante no está obligado a cambiar realmente tu avatar por un meme de un gato: le basta con hacer que tu servidor procese algo durante mucho tiempo y de forma costosa.

También es importante no confundir los roles: CSRF no sustituye a la verificación de «quién es el usuario» ni resuelve el problema de la autorización. Resuelve una cuestión concreta pero crítica: «esta solicitud realmente fue iniciada desde nuestra aplicación dentro de la sesión actual, y no fue inyectada desde fuera».

Comprobación de CSRF en la cadena de filtros

Casi todos los errores con CSRF en los principiantes empiezan igual: «en el controlador lo comprobé todo, pero sigo teniendo 403» o «puse un punto de interrupción y no salta, así que Spring se ha roto». En realidad Spring no se ha roto; simplemente no ha llegado al controlador. CSRF se comprueba dentro de la infraestructura de seguridad, en los filtros, es decir, antes de que Spring MVC empiece a analizar el multipart y a invocar tu método.

El modelo mental es el siguiente: la solicitud llega a la aplicación, pasa por SecurityFilterChain y, en la etapa de CsrfFilter, Spring Security toma el «token esperado» (normalmente de la sesión) y lo compara con el que llegó en la solicitud (de la cabecera o del parámetro). Si el token falta o no coincide, Spring Security no «se queja suavemente», sino que hace lo que debe hacer la capa de seguridad: detiene la solicitud.

Un pequeño esquema para no tenerlo solo en la cabeza:

flowchart TD
    A[Cliente / Postman] --> B[SecurityFilterChain]
    B --> C[CsrfFilter: comparar expected vs actual]
    C -->|OK| D[DispatcherServlet]
    C -->|Fail| E[403 / AccessDenied]
    D --> F[MultipartResolver]
    F --> G[Controlador: POST /api/me/avatar]

Si CSRF falla, tu @PostMapping no será invocado en absoluto. Esto es muy cómodo: en el controlador no es necesario (y no se debe intentar) comprobar manualmente el token. Pero también es fuente de dolor: parece que «nada funciona», aunque en realidad la seguridad ha funcionado demasiado bien.

3. Multipart y el problema del huevo y la gallina

Con el JSON normal todo es sencillo: el token se puede poner en la cabecera, o en un parámetro, o (mejor no) en la URL, y el servidor lo lee fácilmente antes de la lógica de negocio. Pero multipart es una solicitud cuyo cuerpo puede contener un archivo, y para acceder a los parámetros del cuerpo, el servidor a menudo necesita empezar a analizar ese cuerpo. Y el análisis de multipart suele implicar buffering, archivos temporales y, en general, mucho trabajo.

De ahí surge el problema del «huevo y la gallina»: para comprobar el token CSRF que está en el cuerpo de la solicitud multipart (por ejemplo, como campo _csrf), el servidor necesita leer el cuerpo. Pero leer el cuerpo es precisamente lo que nos gustaría hacer después de asegurarnos de que la solicitud es legítima. De lo contrario, resulta que un atacante puede obligar a tu servidor a aceptar y analizar un multipart grande, y luego recibir el rechazo por un token incorrecto.

Imagina a un guardia de seguridad en una discoteca (sí, tampoco pensaba que Spring Security nos llevaría a las discotecas, pero aquí estamos). La opción «token en la cabecera» es como comprobar la pulsera en la entrada. La opción «token en el cuerpo» es como dejar entrar primero a una persona con una maleta enorme, abrir la maleta, contar los calcetines y luego decir: «uy, no tiene pulsera, váyase». Formalmente la seguridad no está rota, pero el proceso es extraño y costoso.

Esto no significa que el token en el parámetro multipart «no se pueda usar». Significa que el enfoque tiene un coste, y debemos entender cuándo lo pagamos conscientemente y cuándo simplemente «salió así».

4. Token CSRF en la cabecera

El método de la cabecera es el más práctico para un cliente API moderno y para cualquier escenario en el que tengas JavaScript (o al menos Postman/curl). Su ventaja clave es que CsrfFilter puede leer la cabecera sin necesidad de analizar el cuerpo multipart, por lo que puede rechazar la solicitud antes y de forma más barata si el token es incorrecto. Esto es especialmente agradable en escenarios de upload.

Elección de la cabecera

En el Spring Security «clásico» de referencia se usa más a menudo la cabecera X-CSRF-TOKEN, y el parámetro _csrf. Pero un matiz importante: los nombres no hay que adivinarlos. Lo mejor es obtenerlos desde el CsrfToken en el servidor, o devolverlos al cliente mediante un endpoint bootstrap separado como /csrf.

Ejemplo: upload con JS y cabecera

Ahora no necesitamos un frontend completo. Basta con ver que técnicamente la solicitud tiene este aspecto:

// Preparamos multipart/form-data
const form = new FormData();
form.append("file", avatarFile); // El nombre del campo debe coincidir con @RequestParam("file")

await fetch("/api/me/avatar", {
  method: "POST",
  body: form, // Cuerpo — multipart; el navegador pondrá el boundary automáticamente
  headers: {
    // Ponemos CSRF en la cabecera para que CsrfFilter pueda comprobarlo antes de analizar el cuerpo multipart
    "X-CSRF-TOKEN": csrfToken
  }
  // Importante: la cookie de sesión (JSESSIONID) debe ser la misma donde el servidor guarda el token esperado
});

Aquí lo importante no es FormData, sino que el token está en la cabecera. El navegador (o Postman) enviará además la cookie de sesión (JSESSIONID), de lo contrario el servidor no encontrará el «token esperado» en la sesión correcta.

Mini-ejemplo: curl (para probar manualmente)

En un proyecto real iniciarás sesión con el navegador, no con curl (de lo contrario tendrías que bailar con el formulario de login y su CSRF). Pero probar el upload en sí es cómodo así:

TOKEN="...tu csrf token..."

# -b cookies.txt: adjuntamos la cookie de sesión, de lo contrario el token será «de otra sesión»
# -H X-CSRF-TOKEN: pasamos CSRF en la cabecera para que la comprobación sea antes de analizar el multipart
curl -b cookies.txt \
  -H "X-CSRF-TOKEN: $TOKEN" \
  -F "file=@./avatar.png" \
  http://localhost:8080/api/me/avatar

Aquí -b cookies.txt significa «adjunta cookies», es decir, la sesión. Sin esto, casi con total seguridad el token no coincidirá, porque el servidor mirará en otra sesión (nueva).

5. Token CSRF en el parámetro multipart

El enfoque del parámetro parece muy lógico si piensas «como un formulario HTML»: añades un campo oculto _csrf y listo. Y efectivamente es un escenario importante, porque un formulario HTML normal no puede ponerte una cabecera personalizada como X-CSRF-TOKEN. Las cabeceras son cosa de JavaScript, mientras que un «formulario puro» está limitado a lo que puede poner en el cuerpo.

Cómo se ve del lado del cliente (HTML)

Aquí hay un ejemplo mínimo de un formulario que envía multipart y coloca el token CSRF en el campo _csrf:


   
    
    
    
    
   Subir 
  

Desde el punto de vista HTTP, es una solicitud multipart legítima: una parte es el campo de texto _csrf, la otra parte es el archivo file.

Cómo se ve con curl/Postman

curl puede hacer lo mismo mediante -F:

# Aquí CSRF va como campo multipart _csrf (como en un formulario HTML), no como cabecera
curl -b cookies.txt \
  -F "_csrf=$TOKEN" \
  -F "file=@./avatar.png" \
  http://localhost:8080/api/me/avatar

Dónde está la compensación

La compensación es exactamente la que mencionamos antes: para obtener _csrf de los parámetros, el servidor puede necesitar empezar a analizar el cuerpo multipart. Dependiendo del contenedor y la configuración, esto puede significar que el archivo temporal para el upload aparezca antes de que CsrfFilter decida definitivamente «dejar pasar o no». Esto no rompe la protección, pero puede hacer que las solicitudes fallidas sean más costosas.

En un proyecto de aprendizaje es normal como parte de la comprensión. En un sistema real, normalmente eliges la opción basada en cabecera para clientes JS, y dejas la opción basada en parámetros para escenarios con «formulario puro».

Token CSRF en la URL

A veces los estudiantes preguntan: «¿Y puedo simplemente hacer un POST /api/me/avatar?_csrf=...?» Técnicamente, sí es posible. Prácticamente, casi siempre es una mala idea, porque la URL es muy «charlatana»: aparece en logs, historial del navegador, puede filtrarse a través de la cabecera Referer, y a veces incluso brilla en monitorizaciones y trazas.

Si tienes muchas ganas de ver cómo queda, aquí tienes un ejemplo (más para experimentar que para producción):

curl -b cookies.txt \
  -F "file=@./avatar.png" \
  "http://localhost:8080/api/me/avatar?_csrf=$TOKEN"

Si todo está configurado para que CsrfFilter permita leer el token de los parámetros (y un parámetro de consulta también es un parámetro de solicitud), esa solicitud podría pasar. Pero en una cultura de ingeniería normal tratamos de no hacer visibles los tokens en la URL. Que el token esté o bien en la cabecera, o bien en el cuerpo del formulario (si no hay otra opción).

6. CSRF y upload en nuestro proyecto

Ahora lo más importante es reunir una receta funcional que no rompa la lógica de Secure Content Platform API. Queremos que el endpoint /api/me/avatar siga siendo simplemente un upload, y que CSRF se compruebe automáticamente por Spring Security antes de llegar al controlador. Por lo tanto, necesitamos una forma clara de obtener el token y otra igual de clara de enviarlo.

Endpoint /csrf de utilidad (si el cliente lo necesita)

Si el cliente necesita un bootstrap del token, es conveniente tener un GET /csrf separado. Este endpoint tiene su propio rol: no cambia nada en el sistema, solo devuelve el token y los nombres de cabecera/parámetro para la sesión actual. Por eso normalmente se abre con una regla de acceso independiente, incluso si los endpoints de negocio viven bajo /api/me/**.

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class CsrfController {

    @GetMapping("/csrf")
    CsrfTokenResponse csrf(CsrfToken token) {
        // Spring Security inyecta automáticamente el CsrfToken si CSRF está activado y la solicitud está dentro de la sesión
        return new CsrfTokenResponse(
                token.getToken(),          // valor del token
                token.getHeaderName(),     // nombre de la cabecera para el cliente (ej. X-CSRF-TOKEN)
                token.getParameterName()   // nombre del parámetro de formulario (ej. _csrf)
        );
    }
}

Y el DTO:

/**
 * DTO para el bootstrap de CSRF en el cliente.
 * Importante: headerName/parameterName es mejor obtenerlos de aquí, no «adivinarlos».
 */
record CsrfTokenResponse(String token, String headerName, String parameterName) {
}

Permitir /csrf por separado no abre las operaciones de negocio al exterior: sin la misma cookie de sesión, ese token no servirá para realizar una solicitud que cambie el estado.

Esto no es un «agujero de seguridad», es un mecanismo de bootstrap del token dentro de la sesión. Pero recuerda: para que el token sea «el mismo», la solicitud a /csrf debe ir en la misma sesión (es decir, con las mismas cookies) en la que luego harás el POST /api/me/avatar.

El controlador de upload no debería conocer CSRF en absoluto

El controlador debe mantenerse lo más posible «sobre el dominio», no «sobre la seguridad». Por ejemplo:

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
class MeAvatarController {

    @PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
    void uploadAvatar(@RequestParam("file") MultipartFile file) {
        // Aquí NO comprobamos CSRF manualmente: CsrfFilter ya lo ha hecho antes en la cadena de filtros
        // Aquí va la validación del archivo (tamaño/tipo) y el guardado para el usuario actual
        // avatarService.uploadForCurrentUser(file);
    }
}

Observa: aquí no hay ni _csrf ni X-CSRF-TOKEN. Es normal. CSRF se comprobará antes de la llamada al método. Si el token no es correcto, el método no se invocará.

Tabla resumen: qué método elegir

Forma de transmitir el token Qué ponemos Dónde es cómodo Ventaja principal Inconveniente principal
Cabecera X-CSRF-TOKEN: ... Cliente JS, Postman, curl Comprobación antes de analizar el cuerpo Se necesita poder establecer una cabecera
Parámetro multipart _csrf como campo de formulario Formulario HTML sin JS Funciona donde no hay cabecera Puede obligar al servidor a analizar multipart antes de rechazar
URL/query ?_csrf=... casi en ningún sitio a veces como «último recurso» filtraciones en logs/historial/Referer

7. Errores típicos con CSRF y upload

En los escenarios de multipart + CSRF hay una clase especial de errores por los que parece que «Spring Security vuelve a hacer magia y molesta». En realidad molesta exactamente en la medida en que no le has proporcionado los datos necesarios. Y la mayoría de las veces no es un error en el código, sino una desincronización entre cómo crees que funciona y cómo funciona realmente la solicitud.

Error nº1: enviaste el token CSRF, pero olvidaste la cookie de sesión.
Si el token se almacena en la sesión (y en la rama stateful suele ser así), el token solo tiene sentido junto con JSESSIONID. Sin cookies, el servidor crea una nueva sesión, con otro token esperado, y recibes «Invalid CSRF token» incluso con un valor «correcto», porque es correcto para otra sesión.

Error nº2: intentan «comprobar CSRF» manualmente en el controlador.
A veces apetece recibir @RequestHeader("X-CSRF-TOKEN") String token y compararlo en el servicio. Es una mala idea: en primer lugar, el filtro ya ha hecho ese trabajo antes; en segundo lugar, corres el riesgo de convertir la seguridad en una lógica duplicada que es fácil de romper. En un esquema correcto, el controlador ni siquiera sabe qué es CSRF; simplemente no se invoca si CSRF falla.

Error nº3: confundir el nombre de la cabecera y el nombre del parámetro.
En un sitio enviaste X-CSRF-TOKEN, en otro X-XSRF-TOKEN, o en lugar de _csrf enviaste csrf. Spring Security no es telépata. Si no estás seguro de qué nombres se esperan, no adivines: devuélvelos desde el endpoint /csrf y usa exactamente lo que diga el servidor.

Error nº4: mezclar el error de CSRF y el error de archivo en una única «validación».
CSRF es un rechazo a nivel de infraestructura de seguridad. El error de archivo es un error de aplicación (archivo vacío, tipo no soportado, tamaño demasiado grande). Ocurren en lugares diferentes y, por sentido, no deberían ser una misma comprobación if (...). Cuando los mantienes separados en tu cabeza, la depuración se vuelve un orden de magnitud más fácil.

Error nº5: «arreglar» el upload mediante .csrf().disable() y pensar que has ganado.
Es la clásica «victoria sobre la alarma a martillazos». Sí, las solicitudes empezarán a pasar. Pero desactivarás una protección importante precisamente donde tienes sesión y cookies. Si el upload no funciona, primero averigua cómo debe enviar el token el cliente (cabecera o parámetro), y solo después toma decisiones arquitectónicas. En nuestro curso, desactivar CSRF «para que funcione» es el camino directo a fijar un mal hábito.

1
Tarea
Spring Security, nivel 13, lección 2
Bloqueada
`CSRF` en el encabezado para multipart upload
`CSRF` en el encabezado para multipart upload
1
Tarea
Spring Security, nivel 13, lección 2
Bloqueada
`CSRF` como parámetro multipart
`CSRF` como parámetro multipart
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION