Spring SecurityLección 08
CSRF, CORS y cabeceras de seguridad
Las tres protecciones que existen porque el navegador envía cookies solo y ejecuta código de otros sitios. Cómo se configuran en Spring Security 7, y cuándo se desactivan.
Al terminar sabrás
- Configurar CSRF para formularios, para SPAs con
csrf.spa()y con excepciones puntuales. - Decidir cuándo es correcto desactivar CSRF.
- Configurar CORS en la cadena de seguridad para que el preflight funcione.
- Conocer las cabeceras de seguridad por defecto y añadir una Content-Security-Policy.
Estos tres mecanismos no dependen de quién eres: existen porque el navegador adjunta cookies automáticamente y ejecuta código de otros sitios. Si tu API no la consume ningún navegador, casi nada de esta lección aplica; si la consume, todo aplica. Los conceptos están en mismo origen, CSRF y CORS; aquí vemos cómo se configuran.
CSRF en Spring Security
Sección titulada «CSRF en Spring Security»- Activado por defecto para
POST,PUT,PATCHyDELETE.GET,HEAD,OPTIONSyTRACEno se protegen: nunca cambies estado con unGET. - El token se guarda en la sesión (
HttpSessionCsrfTokenRepository) y se acepta en la cabeceraX-CSRF-TOKEN(oX-XSRF-TOKEN) o en el parámetro de formulario_csrf. - El manejador por defecto enmascara el token con aleatoriedad en cada respuesta (protección contra el ataque BREACH sobre compresión).
Formularios del servidor
Sección titulada «Formularios del servidor»Con Thymeleaf y th:action, el token se añade solo como campo oculto:
<form th:action="@{/web/notes}" method="post"> <input name="title"> <button>Guardar</button> <!-- Spring añade: <input type="hidden" name="_csrf" value="…"> --></form>SPAs con sesión: csrf.spa()
Sección titulada «SPAs con sesión: csrf.spa()»Para un front (React, Angular, Vue) que usa una cookie de sesión del mismo sitio —el patrón BFF—, Spring Security 7 trae una configuración de una línea que guarda el token en una cookie XSRF-TOKEN legible por JavaScript y resuelve el enmascarado:
http.csrf(csrf -> csrf.spa());function csrfToken() { return document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1];}
await fetch('/web/notes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrfToken() }, body: JSON.stringify({ title: 'Hola' }),});Ojo: el token se renueva tras el login y el logout, así que el front debe volver a leerlo. (Angular y Axios ya leen XSRF-TOKEN y envían X-XSRF-TOKEN automáticamente).
Excepciones puntuales
Sección titulada «Excepciones puntuales»Si una ruta concreta no puede llevar token (un webhook de Stripe o GitHub dentro de una app con sesión), exceptúa solo esa ruta y valida la firma del webhook:
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/**"));¿Cuándo desactivarlo del todo?
Sección titulada «¿Cuándo desactivarlo del todo?»| Tu caso | CSRF |
|---|---|
API stateless con Authorization: Bearer | csrf.disable() es correcto |
| API usada solo por otros servicios | csrf.disable() es correcto |
| App web con sesión y formularios | Activado (por defecto) |
| SPA con sesión en cookie (BFF) | Activado, con csrf.spa() |
| JWT guardado en una cookie | Activado: la cookie vuelve a ir sola |
CORS: configurarlo en la cadena
Sección titulada «CORS: configurarlo en la cadena»Cuando tu front en http://localhost:5173 llama a tu API en http://localhost:8080, el navegador envía primero un preflight OPTIONS sin credenciales. Si Spring Security lo trata como cualquier petición, lo rechaza con 401 y el front ve un error de CORS confuso. Por eso CORS se configura en Spring Security, que deja pasar el preflight antes de autenticar:
@BeanCorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("https://app.example.com", "http://localhost:5173")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN")); config.setAllowCredentials(true); config.setMaxAge(Duration.ofHours(1)); // cachea el preflight
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return source;}http.cors(Customizer.withDefaults()); // usa el bean CorsConfigurationSourceCabeceras de seguridad
Sección titulada «Cabeceras de seguridad»HeaderWriterFilter añade estas cabeceras por defecto:
| Cabecera | Valor por defecto | Protege contra |
|---|---|---|
X-Content-Type-Options | nosniff | Que el navegador interprete un JSON como HTML o script |
X-Frame-Options | DENY | Clickjacking: tu app dentro de un iframe ajeno |
Cache-Control / Pragma / Expires | sin caché | Que respuestas privadas queden en cachés compartidas |
Strict-Transport-Security | 1 año, solo sobre HTTPS | Degradar la conexión de HTTPS a HTTP |
X-XSS-Protection | 0 | Desactiva el antiguo filtro XSS de los navegadores, que causaba más problemas de los que resolvía |
La más potente no viene activada porque depende de tu aplicación: Content-Security-Policy. Para una API que solo devuelve JSON, una política estricta no rompe nada:
http.headers(h -> h .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'none'; frame-ancestors 'none'")) .referrerPolicy(r -> r.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.NO_REFERRER)));Para una aplicación web, empieza con default-src 'self' y ve ampliando. Puedes probarla sin romper nada con la variante Content-Security-Policy-Report-Only (csp.reportOnly()).
Mini lab · 25 min
Ver CSRF y CORS fallar (y arreglarlos)
Objetivo: Reproducir los dos errores más comunes del navegador y entender su causa en el log.
- En la cadena web (con sesión) crea
POST /web/notes. Inicia sesión en el navegador y, desde la consola de DevTools, ejecutafetch('/web/notes', { method: 'POST' }). Lee el logTRACE. - Expón el token con un endpoint y repite la petición enviándolo en la cabecera:
@GetMapping("/web/csrf")CsrfToken csrf(CsrfToken token) { // Spring inyecta el token de la petición return token;}- Crea un
index.htmlestático, sírvelo en otro puerto (npx serve -l 5173opython -m http.server 5173) y haz desde él unfetchahttp://localhost:8080/api/notesconAuthorization: Basic …. Observa el preflight en la pestaña Network. - Configura CORS como arriba y repite.
Preguntas:
- En el paso 1, ¿qué línea del log explica el 403?
- En el paso 3, ¿qué código devolvió el preflight antes de configurar CORS? ¿Llegó la petición real a tu controlador?
- Con
curl, sin cabeceraOrigin, ¿CORS bloquea algo? ¿Qué te dice eso sobre lo que protege CORS?
Pista
Busca en el log mensajes de CsrfFilter sobre un token “missing” o “invalid”.
Mostrar respuestaOcultar respuesta
- Una línea de
CsrfFilterdel tipoInvalid CSRF token found for http://localhost:8080/web/notes, seguida de la respuesta 403 delAccessDeniedHandler. Con la cabeceraX-CSRF-TOKEN(y el valor del endpoint/web/csrf) la petición pasa. - El preflight
OPTIONSrespondió 401 (o 403): llegó sin credenciales, Spring Security lo trató como una petición normal y lo rechazó. Como el preflight falló, el navegador nunca envió la petición real: tu controlador no se ejecutó. Conhttp.cors(...)elCorsFilterresponde el preflight antes de la autenticación. - No bloquea nada:
curlno es un navegador y CORS es una política del navegador. CORS protege a los usuarios del navegador frente a webs de terceros que intentan leer tus respuestas con sus credenciales; no protege tu API de clientes arbitrarios.