CodeGym /Cursos /Spring Security /CSRF en el proyecto: update y delete

CSRF en el proyecto: update y delete

Spring Security
Nivel 11, Lección 3
Disponible

1. CSRF en el proyecto: mapa de operaciones

Cuando oyes «CSRF», dan ganas de abrir inmediatamente SecurityFilterChain y empezar a manipular .csrf(...) como palancas en la cabina de un avión. Pero en el proyecto el orden correcto es otro: primero debemos entender qué operaciones realmente cambian el estado, y solo después alegrarnos de que Spring Security verifica el token. De lo contrario, corremos el riesgo de hacer un state change «silencioso» donde CSRF ni siquiera funciona.

Desde el punto de vista del dominio de nuestro proyecto, el estado cambia de manera bastante cotidiana: el perfil del usuario se actualizó, el texto del borrador cambió, se eliminó un borrador, un borrador «pasó» de DRAFT a SUBMITTED. Son acciones comunes, y es precisamente en ellas donde CSRF es importante. Es importante ver la idea: CSRF está «vinculado» no al URI como cadena, sino al significado de la operación. Y el indicador más simple del significado es el método HTTP.

En nuestro proyecto, la «honestidad» del diseño se ve así: GET debe ser lectura, PATCH/POST/DELETE — modificación. Si empezamos «por comodidad» a eliminar un borrador mediante GET, estamos sacando los cimientos de debajo del modelo CSRF. Es como poner un cinturón de seguridad al asiento con velcro: parece que está, pero en el momento crucial se convierte en decoración.

2. Mini auditoría de la API: lectura y escritura

Cuando el proyecto crece, el principiante cae en una trampa típica: mirar los endpoints uno por uno. Como resultado, CSRF está activado, pero «por alguna razón» falla el perfil, los borradores o el submit. La solución es simple y aburrida (y por lo tanto, correcta): hacer una breve auditoría y dividir explícitamente las operaciones en read y write. Y sí, este es un caso donde una tabla es más útil que el heroísmo.

A continuación, un mapa mínimo para nuestro modelo actual basado en sesión con form login. Aquí ya es útil reunir toda la API en un mapa de read/write, en lugar de mirar un endpoint aleatorio:

Zona Endpoint Método Qué sucede en el sentido ¿Se verifica CSRF?
public /api/public/articles GET leemos artículos publicados no (método seguro)
public /api/public/articles/{slug} GET leemos el artículo no
me /api/me GET leemos «quién soy» no
me /api/me/profile GET leemos el perfil no
me /api/me/profile PATCH actualizamos el perfil sí
drafts /api/drafts GET leemos lista de borradores no
drafts /api/drafts POST creamos un borrador sí
drafts /api/drafts/{id} GET leemos un borrador no
drafts /api/drafts/{id} PATCH actualizamos el borrador sí
drafts /api/drafts/{id} DELETE eliminamos el borrador sí
drafts /api/drafts/{id}/submit POST cambiamos el borrador a SUBMITTED sí

Observa un detalle que el cerebro del principiante a veces sabotea: POST /api/drafts/{id}/submit no es «un POST de servicio cualquiera», sino un verdadero state change. Cambiamos el estado del objeto de dominio, por lo tanto cambiamos el estado de la aplicación. Y CSRF aquí es necesario exactamente por la misma razón que para PATCH o DELETE.

Y al revés: GET /api/drafts/{id} puede ser muy «sensible» desde el punto de vista de la privacidad (no se puede leer un borrador ajeno), pero esto no es un caso CSRF, porque no modifica el estado. Es otro eje de seguridad (authorization), y no lo mezclamos con CSRF.

3. Código: controladores y filtro CSRF

Controladores: métodos HTTP honestos

Luego aterrizamos en el código. Aquí hay una idea importante: la verificación CSRF no debe vivir en el controlador. El controlador debe ser honesto en el sentido de HTTP y el dominio, y CSRF debe ser verificado tranquilamente por CsrfFilter antes de que siquiera lleguemos a @RestController.

Empecemos con el perfil. Necesitamos que el mismo recurso se pueda leer y modificar con diferentes métodos.

package com.example.securecontent.profile;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/me/profile")
public class ProfileController {

    @GetMapping
    public ProfileDto get() {
        // Lectura de estado: CSRF no es necesario aquí (método seguro).
        return new ProfileDto("Neo", "I know CSRF"); // demo
    }

    @PatchMapping
    public ProfileDto update(@RequestBody UpdateProfileRequest request) {
        // Modificación de estado: CSRF es obligatorio, el filtro verificará el token ANTES de entrar aquí.
        return new ProfileDto(request.displayName(), request.bio());
    }
}

Y DTO en un formato que no asusta al principiante (y es muy amigable con JSON):

package com.example.securecontent.profile;

// record coincide cómodamente en forma con un objeto JSON: campos -> claves, constructor -> obligatoriedad.
public record UpdateProfileRequest(String displayName, String bio) { }
public record ProfileDto(String displayName, String bio) { }

Los borradores: la misma lógica, solo que las operaciones son más variadas. Es importante no intentar «ahorrar URL» y no convertir todo en POST /api/drafts/doSomething.

package com.example.securecontent.content;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/drafts")
public class DraftController {

    @PatchMapping("/{id}")
    public void update(@PathVariable long id, @RequestBody UpdateDraftRequest req) {
        // Modificación de estado: actualizamos campos del borrador (title/body).
        // Importante: nada de operaciones GET "cómodas" para update.
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable long id) {
        // Modificación de estado: eliminamos el borrador.
        // Si fuera GET, en la configuración normal de Spring Security
        // no se realizaría la verificación CSRF, porque el framework asume
        // que GET sigue siendo lectura.
    }
}

El DTO para actualizar el borrador también se puede mantener compacto:

package com.example.securecontent.content;

// Contrato mínimo para PATCH: solo lo que realmente actualizamos.
public record UpdateDraftRequest(String title, String body) { }

Lo más importante en esta sección no son los métodos en sí, sino la disciplina: GET no debe cambiar el estado. Si alguna vez añades un endpoint «cómodo» @GetMapping("/{id}/delete"), no solo violarás la etiqueta REST. Crearás un agujero donde la mecánica CSRF en la configuración normal de Spring Security no funcionará, porque GET para el framework es un método seguro.

Dónde funciona CSRF en la cadena

CSRF a menudo se confunde con «validación en el controlador», porque el principiante piensa en los controladores como «entrada al sistema». Pero en Spring Security, la entrada al sistema son los filtros. Por lo tanto, CSRF se verifica antes del controlador, aproximadamente donde antes ya vimos la restauración de SecurityContext.

Si lo dibujamos de manera muy simplificada, obtenemos el siguiente esquema:

flowchart TD
    A["Browser"] -->|JSESSIONID cookie| B["Security Filter Chain"]
    A -->|X-CSRF-TOKEN header| B
    B --> C["CsrfFilter: compare expected vs actual"]
    C -->|OK| D["Authorization filter"]
    D -->|OK| E["DispatcherServlet"]
    E --> F["@RestController"]
    C -->|Fail| X["403 (controller not executed)"]

Por defecto, Spring Security realiza esta verificación solo en métodos inseguros y omite GET, HEAD, TRACE, OPTIONS. Por lo tanto, los métodos HTTP honestos no son una formalidad, sino un pilar para el modelo de seguridad.

Un beneficio «psicológico» importante de este esquema: explica por qué, cuando hay un rechazo CSRF, es posible que no veas registros de tu controlador/servicio. Simplemente no se ejecutan. Y es normal: la capa de protección actuó antes.

Otro punto sutil: CSRF y la autorización no compiten. Responden preguntas diferentes. La autorización responde «¿puede este usuario?». CSRF responde «¿parece que la solicitud proviene de un escenario de navegador correcto y no de una página atacante?». Si la respuesta a cualquiera de estas preguntas es «no», la solicitud debe morir antes del código de negocio. A veces parece cruel, pero al menos es predecible.

4. Práctica: PATCH del perfil con token

Ahora hagamos aquello por lo que realmente es esta lección: tomar un endpoint que cambia el estado y verlo sin misterios. Como «conejillo de indias» es ideal PATCH /api/me/profile, porque es muy real: el usuario cambia displayName y bio.

Imaginemos una situación típica para aplicaciones basadas en sesión: la aplicación está en ejecución, formLogin está activado, el usuario ha iniciado sesión, en el navegador aparece la cookie JSESSIONID. Ahora queremos hacer una solicitud PATCH. Para ello necesitamos dos cosas: mantener esa sesión (cookie) y enviar el token CSRF.

Para la depuración educativa, es útil tener un endpoint de depuración temporal solo local. En el flujo de producción, el navegador no lo necesita: él mismo recoge el formulario y el token. Pero para curl o Postman, es útil ampliar temporalmente el endpoint de depuración ya conocido y agregar el valor del token. Esto no es una «característica del producto», sino una herramienta para ver manualmente la relación token + sesión. Dicho endpoint es mejor mantenerlo bajo el perfil local o esconderlo bajo /debug/**.

package com.example.securecontent.security.debug;

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

@RestController
public class CsrfDebugController {

    @GetMapping("/debug/csrf")
    Map<String, String> csrf(CsrfToken token) {
        // local-only: para diagnóstico manual mostramos no solo los nombres,
        // sino también el valor del token. No es parte de la API de producción.
        return Map.of(
                "headerName", token.getHeaderName(),
                "parameterName", token.getParameterName(),
                "token", token.getToken()
        );
    }
}

Ahora el escenario manual se ve así (y sí, es uno de esos raros momentos en que curl ayuda a entender Spring Security más rápido que 40 minutos de filosofía):

# 1) Iniciamos sesión en el navegador a través de /login (creamos sesión)

# 2) En el mismo navegador abrimos /debug/csrf (solo local) y copiamos el token del JSON.
#    Paralelamente, en DevTools vemos la cookie JSESSIONID y copiamos su valor.

# 3) Hacemos PATCH con cookie + encabezado CSRF
curl -i \
  -H "Content-Type: application/json" \
  -H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
  -H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
  -X PATCH \
  -d '{"displayName":"Trinity","bio":"PATCH is my cardio"}' \
  http://localhost:8080/api/me/profile

Si quitas el encabezado X-CSRF-TOKEN pero dejas la cookie, verás un rechazo «malvado». Y es muy ilustrativo: el servidor «reconoció» al usuario por la cookie de sesión, pero no recibió confirmación del flujo correcto del navegador. Eso es exactamente el significado de CSRF.

Y si quitas la cookie pero dejas el token, también habrá rechazo, porque el token en nuestro modelo actual vive en HttpSession, y sin el contexto de sesión se convierte en una simple cadena aleatoria. El token CSRF no «reemplaza» la autenticación, la complementa.

5. Borradores: PATCH, DELETE, submit

Después del perfil, los borradores suelen «explotar la mente» del principiante por dos razones. Primero, hay más. Segundo, hay operaciones que parecen un flujo de trabajo (submit), y uno tiende a verlas como una excepción a las reglas. En realidad, todo es aburrido y maravilloso: si la operación cambia el estado del dominio, CSRF la trata exactamente igual que a update/delete.

Tomemos la actualización del borrador:

# PATCH cambia el estado -> necesita token CSRF + la misma sesión (cookie)
curl -i \
  -H "Content-Type: application/json" \
  -H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
  -H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
  -X PATCH \
  -d '{"title":"New title","body":"New body"}' \
  http://localhost:8080/api/drafts/10

Eliminación del borrador: lo mismo, solo que sin body:

# DELETE cambia el estado -> también requiere token CSRF
curl -i \
  -H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
  -H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
  -X DELETE \
  http://localhost:8080/api/drafts/10

Y ahora submit. Es tentador hacerlo «como un enlace», porque «es solo una acción». Pero desde el punto de vista de seguridad, submit es un cambio de estado, por lo tanto debe seguir siendo un método inseguro.

# submit cambia el estado del objeto de dominio -> también es un state change
curl -i \
  -H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
  -H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
  -X POST \
  http://localhost:8080/api/drafts/10/submit

Si todo esto empieza a funcionar con token, y sin token no, entonces has hecho algo importante: te has asegurado de que CSRF en el proyecto no es magia mítica de Spring, sino una regla predecible: «los métodos inseguros requieren una prueba adicional del escenario correcto».

6. Reglas de diseño para CSRF

En un proyecto de aprendizaje es especialmente fácil «romper accidentalmente» el modelo CSRF, porque se quiere ver el resultado rápido, no mantener la disciplina del diseño de API. Pero CSRF ama la disciplina: se basa en la suposición de que los métodos seguros siguen siendo seguros. Por eso necesitamos reglas simples, casi cotidianas, que mantengan el proyecto en un estado adecuado.

Es útil pensar no en la configuración de Spring Security, sino en este par de diseño «malo/bueno» (y sí, es aburrido, significa que funcionará):

Tarea Opción mala Opción buena
Eliminar borrador GET /api/drafts/{id}/delete DELETE /api/drafts/{id}
Enviar a moderación GET /api/drafts/{id}/submit POST /api/drafts/{id}/submit
Actualizar perfil POST /api/me/profile/update PATCH /api/me/profile

En las opciones buenas, la protección CSRF se activa naturalmente. Spring Security y el modelo del navegador te ayudan porque no vas en contra del significado de HTTP. En las opciones malas, tú mismo eliminas las señales por las que la protección debería activarse. Y luego empieza el folclore: «Spring Security vuelve a hacer cosas raras». No, no es Spring. Somos nosotros.

Y aquí también una disciplina mental importante: la autorización a nivel de solicitud y CSRF son dos capas. Incluso si PATCH /api/drafts/{id} solo está disponible para usuarios autenticados, CSRF sigue siendo necesario porque responde a otra pregunta. No «puede» sino «¿realmente fue él quien presionó el botón, y no su navegador obedeciendo una orden ajena?».

7. Errores típicos con CSRF

En este tema es fácil caer en dos estados extremos: o «lo entendí todo, CSRF es un token» (y luego quedarse atascado en el primer 403), o «bah, lo desactivo y sigo adelante» (y construir un hábito muy peligroso en la mente). Por eso, es útil hablar de los errores no como historias de terror, sino como rastrillos normales, casi inevitables.

Error n.º 1: ocultar el cambio de estado detrás de GET, porque «es más cómodo hacer clic en un enlace».
Más cómodo sí, pero exactamente hasta que te das cuenta de que la protección CSRF no funciona por definición, porque GET en el modelo HTTP debe ser seguro. En nuestro proyecto, la eliminación y el submit deben seguir siendo DELETE y POST, aunque parezca «complejidad innecesaria».

Error n.º 2: pensar que CSRF está vinculado al URI, no a la operación.
El principiante ve /api/me/profile y cree que «esta URL es CSRF o no lo es». En la práctica, CSRF se aplica a PATCH /api/me/profile, pero no se aplica a GET /api/me/profile. Es el mismo URI, pero dos operaciones diferentes. Si aceptas esto, la mitad de la confusión desaparece.

Error n.º 3: mirar los endpoints uno por uno, no como un mapa de acciones read/write.
Cuando tienes cinco controladores y veinte métodos, el cerebro empieza a «optimizar» y se olvida del submit, del delete, del update. La tabla de auditoría (como en medio de la lección) ahorra nervios: se ve dónde están los cambios de estado, y por lo tanto dónde CSRF debe estar.

Error n.º 4: confundir el rechazo CSRF con un problema de roles o inicio de sesión.
Un rechazo CSRF puede verse como un 403, y automáticamente lleva a pensar «me falta un rol». Pero CSRF no es sobre roles. Es sobre que la solicitud no confirmó el escenario correcto. Por eso es importante primero verificar la cookie de sesión y el token, y luego meterse en hasRole(...).

Error n.º 5: «perder la sesión» accidentalmente y luego sorprenderse de que el token «de repente dejó de funcionar».
El token en nuestra línea base vive en HttpSession. Si la sesión cambia o expira, el valor esperado desaparece, y la siguiente solicitud de cambio de estado falla. Aquí es útil recordar: el token CSRF y la sesión son un conjunto, no entidades independientes.

1
Tarea
Spring Security, nivel 11, lección 3
Bloqueada
PATCH /api/me/profile con cookie y token CSRF
PATCH /api/me/profile con cookie y token CSRF
1
Tarea
Spring Security, nivel 11, lección 3
Bloqueada
Draft workflow sin magía: GET, PATCH, submit y DELETE
Draft workflow sin magía: GET, PATCH, submit y DELETE
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION