← Sesión, autenticación y Spring Security

Sesión 40 · Semana 20

CSRF, CORS con credenciales y cierre seguro

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:

  1. Has iniciado sesión en tu banco (https://tu-banco.com). El servidor te asignó una cookie de sesión que tu navegador almacena.
  2. Sin cerrar sesión, abres otra pestaña y visitas un foro o una web sospechosa (https://web-maliciosa.com).
  3. La web maliciosa contiene este código oculto:
    <img src="https://tu-banco.com/api/transferir?destino=cuenta-atacante&cantidad=1000" style="display:none;" />
  4. El navegador intenta cargar la imagen haciendo una petición GET a tu-banco.com.
  5. ¡El peligro! Como la petición va dirigida a tu-banco.com, el navegador adjunta automáticamente la cookie de sesión del banco.
  6. El servidor del banco recibe la petición con una cookie válida y ejecuta la transferencia pensando que la ordenaste tú.
El ataque CSRF
  1. 1. Usuario con sesión activa en tu-banco.com
  2. 2. Abre web-maliciosa.com en otra pestaña
  3. 3. Script oculto lanza petición a tu-banco.com
  4. 4. Navegador adjunta cookies automáticamente
  5. 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

  1. 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.
  2. Ejecuta acceso, operación autorizada y cierre de sesión o retirada del token. Observa cabeceras, cookies y peticiones OPTIONS en Red.
  3. 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.

  1. Abre cliente/index.html en http://localhost:5500:
    • La lista de proyectos públicos se carga de inmediato sin pedir credenciales (200 OK).
  2. Intenta crear un proyecto sin hacer login:
    • El formulario emite un POST sin cabecera Authorization.
    • 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”.
  3. Inicia sesión en la interfaz:
    • Introduces usuario admin y clave Password123!.
    • El backend responde con el token JWT. El cliente lo almacena en sessionStorage.
  4. Vuelve a crear el proyecto:
    • El cliente incluye Authorization: Bearer <token>.
    • El preflight OPTIONS responde 200 y el POST devuelve 201 Created.
    • El nuevo proyecto aparece en pantalla al instante sin recargar la página.
  5. 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.

  1. 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.
  2. Intenta crear un proyecto tras el cierre de sesión y verifica que el servidor responde 401.
  3. 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í:

  1. Quita la palabra Bearer de la cabecera y deja solo el token.
  2. Activa CSRF (csrf sin .disable()) y lanza un POST.
  3. Quita tu origen de la lista de CORS con credentials activadas.
  4. Usa un token caducado (baja expiration-minutes a 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, nunca 401 ni 500.
  • 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-seguridad ejecuta 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

  1. Distingue cada fallo por su petición y respuesta, y comprueba que corregir CORS no elimina controles de identidad o permisos.
  2. 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:

  1. Diseña una página HTML maliciosa local (atacante.html) servida en el puerto 9000 con un formulario oculto que envíe un POST automático hacia un endpoint protegido por cookies.
  2. Observa cómo el navegador adjunta las cookies y el backend vulnerable ejecuta la orden.
  3. Activa en Spring Security el CookieCsrfTokenRepository.withHttpOnlyFalse() y observa cómo el servidor neutraliza el ataque respondiendo 403 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.

Objetivo mínimoMecanismo de ataque CSRF comprendido y configuración unificada de CORS y seguridad aplicada.
Si lo tienesFlujo completo de autenticación por JWT integrado en el cliente web de la UD8 con gestión de errores.
RetoSimulación de ataque CSRF ejecutada en laboratorio y contramedida con tokens sincronizados verificada.
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.