Ir al contenido

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.

Intermedio16 min de lectura

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.

  • Activado por defecto para POST, PUT, PATCH y DELETE. GET, HEAD, OPTIONS y TRACE no se protegen: nunca cambies estado con un GET.
  • El token se guarda en la sesión (HttpSessionCsrfTokenRepository) y se acepta en la cabecera X-CSRF-TOKEN (o X-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).

Con Thymeleaf y th:action, el token se añade solo como campo oculto:

note-form.html
<form th:action="@{/web/notes}" method="post">
<input name="title">
<button>Guardar</button>
<!-- Spring añade: <input type="hidden" name="_csrf" value="…"> -->
</form>

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:

SecurityConfig.java
http.csrf(csrf -> csrf.spa());
api.js
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).

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/**"));
Tu casoCSRF
API stateless con Authorization: Bearercsrf.disable() es correcto
API usada solo por otros servicioscsrf.disable() es correcto
App web con sesión y formulariosActivado (por defecto)
SPA con sesión en cookie (BFF)Activado, con csrf.spa()
JWT guardado en una cookieActivado: la cookie vuelve a ir sola

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:

CorsConfig.java
@Bean
CorsConfigurationSource 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;
}
SecurityConfig.java
http.cors(Customizer.withDefaults()); // usa el bean CorsConfigurationSource

HeaderWriterFilter añade estas cabeceras por defecto:

CabeceraValor por defectoProtege contra
X-Content-Type-OptionsnosniffQue el navegador interprete un JSON como HTML o script
X-Frame-OptionsDENYClickjacking: tu app dentro de un iframe ajeno
Cache-Control / Pragma / Expiressin cachéQue respuestas privadas queden en cachés compartidas
Strict-Transport-Security1 año, solo sobre HTTPSDegradar la conexión de HTTPS a HTTP
X-XSS-Protection0Desactiva 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:

SecurityConfig.java
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.

  1. En la cadena web (con sesión) crea POST /web/notes. Inicia sesión en el navegador y, desde la consola de DevTools, ejecuta fetch('/web/notes', { method: 'POST' }). Lee el log TRACE.
  2. 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;
}
  1. Crea un index.html estático, sírvelo en otro puerto (npx serve -l 5173 o python -m http.server 5173) y haz desde él un fetch a http://localhost:8080/api/notes con Authorization: Basic …. Observa el preflight en la pestaña Network.
  2. 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 cabecera Origin, ¿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 CsrfFilter del tipo Invalid CSRF token found for http://localhost:8080/web/notes, seguida de la respuesta 403 del AccessDeniedHandler. Con la cabecera X-CSRF-TOKEN (y el valor del endpoint /web/csrf) la petición pasa.
  • El preflight OPTIONS respondió 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ó. Con http.cors(...) el CorsFilter responde el preflight antes de la autenticación.
  • No bloquea nada: curl no 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.