Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado comprobar roles y propiedad en el proceso de revisión. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
La identidad y los permisos ya están integrados. Hoy revisarás su comportamiento real en el navegador. CSRF es una petición no deseada que aprovecha credenciales enviadas automáticamente; CORS regula lectura entre orígenes. Son problemas distintos y dependen de cómo transportes las credenciales.
Anatomía de un ataque CSRF (Cross-Site Request Forgery)
Imagina este escenario:
- Has iniciado sesión en tu banco (
https://tu-banco.com). El servidor te asignó una cookie de sesión que tu navegador almacena. - Sin cerrar sesión, abres otra pestaña y visitas un foro o una web sospechosa (
https://web-maliciosa.com). - La web maliciosa contiene este código oculto:
<img src="https://tu-banco.com/api/transferir?destino=cuenta-atacante&cantidad=1000" style="display:none;" /> - El navegador intenta cargar la imagen haciendo una petición
GETatu-banco.com. - ¡El peligro! Como la petición va dirigida a
tu-banco.com, el navegador adjunta automáticamente la cookie de sesión del banco. - El servidor del banco recibe la petición con una cookie válida y ejecuta la transferencia pensando que la ordenaste tú.
- 1. Usuario con sesión activa en tu-banco.com
- 2. Abre web-maliciosa.com en otra pestaña
- 3. Script oculto lanza petición a tu-banco.com
- 4. Navegador adjunta cookies automáticamente
- 5. Servidor ejecuta la orden sin consentimiento
Cómo influye el transporte de credenciales en CSRF
CSRF aprovecha credenciales que el navegador adjunta automáticamente, como una cookie de sesión; también puede afectar a HTTP Basic. Que una API sea stateless o utilice JWT no elimina por sí solo este riesgo.
Cuando usamos tokens JWT transmitidos en la cabecera personalizada:
Authorization: Bearer <token>
- El navegador jamás adjunta esa cabecera por su cuenta.
- Para enviar esa cabecera, un script JavaScript tiene que leer el token de memoria y colocarlo explícitamente en el objeto
fetch(). - Si la API acepta únicamente ese Bearer añadido explícitamente y ninguna credencial automática alternativa, desaparece el mecanismo habitual de CSRF. Un JWT enviado en una cookie exige otra evaluación. Esto tampoco protege frente a XSS o al robo del token.
La regla de desactivación de CSRF
Antes de desactivar CSRF, verifica todos los mecanismos de autenticación aceptados. Con credenciales que el navegador envía automáticamente, mantén y configura la protección. Spring permite guardar el token esperado en sesión o usar un repositorio como CookieCsrfTokenRepository; el cliente debe devolver el token de comprobación explícitamente.
Referencia: protección CSRF de Spring Security y configuración para aplicaciones Servlet.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre la configuración final de seguridad y el código del cliente. Identifica si la autenticación usa cookie de sesión, cookie con token o Authorization explícita.
- Ejecuta acceso, operación autorizada y cierre de sesión o retirada del token. Observa cabeceras, cookies y peticiones OPTIONS en Red.
- Prepara un caso sin identidad, uno sin permiso y otro de origen no permitido. Añade el caso de protección CSRF que corresponda al mecanismo elegido.
Paso 2 · Tabla de diagnóstico de los 4 errores clásicos de Spring Security
Durante la integración entre cliente web y backend protegido se presentan siempre los mismos cuatro tropiezos:
| Error observable | Dónde aparece | Causa técnica real | Solución exacta |
|---|---|---|---|
| 1 · 401 Unauthorized silencioso | Pestaña Network de DevTools. | El cliente no envió la cabecera Authorization o la envió con un formato incorrecto (ej: falta la palabra Bearer ). |
Revisar el script de fetch: asegurar que añade headers: { 'Authorization': 'Bearer ' + token }. |
| 2 · 403 Forbidden inesperado en POST/PUT | Consola y Network en aplicaciones con sesión. | La protección CSRF de Spring Security está activa y la petición de modificación no aportó el token X-XSRF-TOKEN. |
Si usas JWT: asegurar csrf.disable(). Si usas cookies: leer el token de la cookie XSRF-TOKEN y reenviarlo en la cabecera X-XSRF-TOKEN. |
| 3 · Error de CORS al enviar credenciales | Consola de JavaScript en rojo. | El cliente usó credentials: 'include', pero el backend configuró allowedOrigins("*"). |
En Spring Boot WebConfig, sustituir el comodín * por los orígenes exactos (allowedOrigins("http://localhost:5500")). |
| 4 · Token Expired (401 tras un tiempo) | Consola de JavaScript. | La fecha exp del JWT quedó en el pasado y JwtService rechazó la firma. |
Redirigir al usuario a la pantalla de login o solicitar un nuevo token mediante el endpoint de refresco (Refresh Token). |
Paso 3 · Configuración final unificada de seguridad y CORS
Integra el bloque en la SecurityConfig existente, conservando los beans del encoder y AuthenticationManager que necesita el login. Revisa el orden: CORS, política de sesión, rutas públicas, rutas protegidas y filtro JWT. Copia a allowedOrigins el origen real del cliente. Retira la configuración CORS duplicada de WebConfig si la sustituyes por CorsConfigurationSource; mantén una única definición que puedas explicar y probar.
package com.ejemplo.gestor.config;
import com.ejemplo.gestor.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthFilter) throws Exception {
http
// 1. Vinculamos la configuración de CORS al ciclo de vida de Spring Security
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 2. Desactivamos CSRF porque nos autenticamos exclusivamente por Bearer tokens sin cookies
.csrf(csrf -> csrf.disable())
// 3. API estrictamente sin estado
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 4. Reglas de autorización de rutas
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/proyectos/**").hasRole("ADMINISTRADOR")
.requestMatchers("/api/v1/usuarios/**").hasRole("ADMINISTRADOR")
.anyRequest().authenticated()
)
.exceptionHandling(errores -> errores
.authenticationEntryPoint((peticion, respuesta, error) -> {
respuesta.setStatus(401);
respuesta.setContentType("application/problem+json");
respuesta.getWriter().write("{\"title\":\"Unauthorized\",\"status\":401,\"detail\":\"Inicia sesión\"}");
})
.accessDeniedHandler((peticion, respuesta, error) -> {
respuesta.setStatus(403);
respuesta.setContentType("application/problem+json");
respuesta.getWriter().write("{\"title\":\"Forbidden\",\"status\":403,\"detail\":\"Permiso insuficiente\"}");
}))
// 5. Inyectamos nuestro filtro de JWT antes del filtro de usuario/contraseña
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Orígenes explícitos (nunca comodín '*' cuando hay credenciales)
config.setAllowedOrigins(List.of("http://localhost:5500", "http://127.0.0.1:5500"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setExposedHeaders(List.of("Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Paso 4 · El flujo de integración completo de cliente a servidor
Prepara dos cuentas con permisos distintos y abre Red antes del primer envío. Ejecuta lectura pública, escritura sin token, login, escritura permitida y escritura denegada a la otra cuenta. En cada caso comprueba primero si hubo respuesta HTTP o bloqueo CORS y después el estado. Si pruebas expiración, configura una duración corta en desarrollo, emite un token nuevo y espera a que venza; cambiar la configuración no modifica los tokens ya emitidos.
- Abre
cliente/index.htmlenhttp://localhost:5500:- La lista de proyectos públicos se carga de inmediato sin pedir credenciales (
200 OK).
- La lista de proyectos públicos se carga de inmediato sin pedir credenciales (
- Intenta crear un proyecto sin hacer login:
- El formulario emite un
POSTsin cabeceraAuthorization. - En DevTools Network compruebas el código
401 Unauthorized. - La interfaz muestra el mensaje en rojo: “Debes iniciar sesión para realizar esta acción”.
- El formulario emite un
- Inicia sesión en la interfaz:
- Introduces usuario
adminy clavePassword123!. - El backend responde con el token JWT. El cliente lo almacena en
sessionStorage.
- Introduces usuario
- Vuelve a crear el proyecto:
- El cliente incluye
Authorization: Bearer <token>. - El preflight
OPTIONSresponde200y elPOSTdevuelve201 Created. - El nuevo proyecto aparece en pantalla al instante sin recargar la página.
- El cliente incluye
- Verifica la consola: Cero errores de CORS, cero errores de CSRF, cero fugas de seguridad.
Paso 5 · La auditoría de cierre de la unidad
Esta es la última sesión de la UD9: lo que no quede blindado hoy, llega así al proyecto final.
- Añade un botón «Cerrar sesión» en la cabecera de tu página, que borre el token del navegador y actualice la pantalla.
- Intenta crear un proyecto tras el cierre de sesión y verifica que el servidor responde
401. - La pregunta incómoda: copia el token antes de cerrar sesión, ciérrala, y vuelve a lanzar la petición pegando ese token a mano en tu cliente HTTP. Funciona. Acabas de comprobar que en una arquitectura sin estado el cierre de sesión es un gesto del cliente, no del servidor: el token sigue siendo válido hasta que caduque. Anota en tu cuaderno las dos formas de resolverlo —tokens de vida corta o una lista negra en base de datos— y cuál de las dos reintroduce estado en el servidor.
Provoca a propósito los cuatro fallos de la tabla de esta sesión y, en cada uno, anota qué se ve en la consola del navegador, qué se ve en la pestaña Red y qué se ve en los logs del servidor. Son tres puntos de vista del mismo problema, y saber cuál mirar primero es la competencia que se lleva de aquí:
- Quita la palabra
Bearerde la cabecera y deja solo el token. - Activa CSRF (
csrfsin.disable()) y lanza unPOST. - Quita tu origen de la lista de CORS con
credentialsactivadas. - Usa un token caducado (baja
expiration-minutesa 1 y espera).
Recorre esta lista sobre tu propio proyecto. Cada punto que no puedas marcar es trabajo pendiente, no una observación:
- Ninguna contraseña se guarda ni se registra en claro, tampoco en los logs.
- Ningún endpoint de escritura responde sin credenciales.
- Un usuario autenticado sin permisos recibe
403, nunca401ni500. - Ningún DTO de respuesta publica la contraseña, ni siquiera su hash.
- El secreto del JWT no está escrito en el código ni subido al repositorio.
- Las rutas públicas son exactamente tres y sabes nombrarlas: documentación, login y poco más.
- Los tests de seguridad de la sesión 38 siguen en verde tras todos los cambios de la semana 20.
- La colección
09-seguridadejecuta la matriz completa y todas las peticiones devuelven lo esperado.
- Cómo saber que lo has terminado
- Sabes reconocer los cuatro fallos por su síntoma sin tener que probar a ciegas; entiendes por qué un token sigue siendo válido después de cerrar sesión y qué harías al respecto; y los ocho puntos de la auditoría están marcados.
Paso 6 · Comprobar y registrar el resultado del proyecto
- Distingue cada fallo por su petición y respuesta, y comprueba que corregir CORS no elimina controles de identidad o permisos.
- Repite el flujo completo desde el navegador y documenta cómo se envían las credenciales y qué protección CSRF requiere esa decisión.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Simulación de ataque CSRF y su contramedida
Para entender la gravedad de CSRF, realiza una prueba de concepto en un entorno controlado:
- Diseña una página HTML maliciosa local (
atacante.html) servida en el puerto 9000 con un formulario oculto que envíe unPOSTautomático hacia un endpoint protegido por cookies. - Observa cómo el navegador adjunta las cookies y el backend vulnerable ejecuta la orden.
- Activa en Spring Security el
CookieCsrfTokenRepository.withHttpOnlyFalse()y observa cómo el servidor neutraliza el ataque respondiendo403 Forbidden (Invalid CSRF Token).
Formato de entrega
Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.
Ver respuestas
1 · Porque el navegador no añade cabeceras personalizadas de forma automática en peticiones cruzadas; para enviarla se requiere JavaScript explícito del propio origen legítimo.
2 · Configurar un CorsConfigurationSource vinculado formalmente a http.cors() dentro de la SecurityFilterChain.
3 · Porque el servidor no mantiene ningún registro del token en memoria; al borrar el token del cliente, este pierde la capacidad de firmar y autorizar peticiones futuras.
4 · Falta del token sincronizado anti-CSRF (la cabecera X-XSRF-TOKEN o parámetro _csrf no fue incluido en la petición).
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El cliente autorizado funciona y quedan probados los rechazos por identidad, permisos y configuración web.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.