Manejo de CORS en Aplicaciones Spring Boot: Estrategias de Configuración

Introducción a CORS y la Política del Mismo Origen

En el desarrollo web moderno, es común que una aplicación frontend, como una SPA (Single Page Application) basada en React, Angular o Vue.js, interactúe con un servicio backend desplegado en un dominio o puerto diferente. Esta interacción puede verse frustrada por una característica de seguridad fundamental de los navegadores web: la Política del Mismo Origen (Same-Origin Policy o SOP).

¿Qué es la Política del Mismo Origen?

La SOP es un mecanismo de seguridad implementado en los navegadores que restringe cómo un documento o script cargado desde un origen puede interactuar con un recurso de otro origen. Un "origen" se define por la combinación de su protocolo, host (dominio) y puerto. Si alguno de estos tres componentes difiere, los orígenes se consideran distintos, y la SOP entra en juego para evitar posibles vulnerabilidades de seguridad como el robo de datos o el acceso no autorizado.

¿Cuándo ocurre una solicitud de Origen Cruzado (CORS)?

Una solicitud se considera de origen cruzado si el protocolo, el host o el puerto de la URL de la solicitud difieren del origen de la página web desde la que se realiza la solicitud.

URL de la Página Actual URL de la Solicitud ¿Es Origen Cruzado? Motivo
http://www.ejemplo.com/ http://www.ejemplo.com/pagina.html No Mismo origen (protocolo, dominio, puerto iguales)
http://www.ejemplo.com/ https://www.ejemplo.com/pagina.html Protocolo diferente (http vs https)
http://www.ejemplo.com/ http://www.otrodominio.com/ Dominio diferente (ejemplo vs otrodominio)
http://www.ejemplo.com:8080/ http://www.ejemplo.com:7001/ Puerto diferente (8080 vs 7001)

Restricciones por la Política del Mismo Origen

Cuando los orígenes son diferentes, la SOP impone varias restricciones, incluyendo:

  • Imposibilidad de leer cookies, LocalStorage o IndexedDB del otro origen.
  • Prohibición de acceder al DOM de una página cargada desde otro origen.
  • Bloqueo de solicitudes AJAX (XMLHttpRequest o Fetch API) a URLs de otros orígenes, a menos que el servidor de destino lo permita explícitamente mediante CORS.

Escenario de Ejemplo

Consideremos una arquitectura de aplicación típica donde el frontend y el backend están separados. Por ejemplo, el frontend se ejecuta en http://localhost:8848/ y el servicio de backend en http://api.miservicio.com:8082/. Cualquier intento del frontend de acceder directamente al backend resultará en un error de CORS, ya que los dominios y puertos son distintos.

Controlador Backend (Spring Boot)

Aquí se muestra un ejemplo de un controlador REST en Spring Boot que expone varios puntos finales:

package com.example.app.api;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Objects;

// DTO para manejar datos de usuario en solicitudes POST
class UserData {
    private String username;
    private String passwordHash;

    // Getters y Setters
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }

    @Override
    public String toString() {
        return "UserData{username='" + username + "', passwordHash='" + passwordHash + "'}";
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserData userData = (UserData) o;
        return Objects.equals(username, userData.username) && Objects.equals(passwordHash, userData.passwordHash);
    }
    @Override
    public int hashCode() {
        return Objects.hash(username, passwordHash);
    }
}

// DTO para manejar datos genéricos en JSON
class GenericRequestPayload {
    private String key;
    private Object value;

    // Getters y Setters
    public String getKey() { return key; }
    public void setKey(String key) { this.key = key; }
    public Object getValue() { return value; }
    public void setValue(Object value) { this.value = value; }

    @Override
    public String toString() {
        return "GenericRequestPayload{key='" + key + "', value=" + value + "}";
    }
}

@RestController
@RequestMapping("/api/v1")
public class AppGatewayController {

    @GetMapping("/greet")
    public ResponseEntity<String> sendGreeting(@RequestParam String recipient) {
        return ResponseEntity.ok("Saludos, " + recipient + " desde el backend!");
    }

    @GetMapping("/data/info")
    public ResponseEntity<String> retrieveSpecificData(@RequestParam String paramId, @RequestParam(required = false) String token) {
        return ResponseEntity.ok("Datos para ID: " + paramId + (token != null ? " con token: " + token : ""));
    }

    @PostMapping("/user/register")
    public ResponseEntity<UserData> processUserRegistration(@RequestBody UserData userDetails) {
        // Lógica para registrar usuario
        return ResponseEntity.ok(userDetails);
    }

    @PostMapping("/process/payload")
    public ResponseEntity<Map<String, Object>> handleJsonPayload(@RequestBody Map<String, Object> payload) {
        payload.put("status", "processed");
        payload.put("timestamp", System.currentTimeMillis());
        return ResponseEntity.ok(payload);
    }
}

Página Frontend (HTML/JavaScript)

El siguiente código HTML y JavaScript (usando jQuery) intenta realizar solicitudes a los puntos finales del backend. Sin una configuración CORS adecuada, estas solicitudes fallarán en el navegador.


<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Demostración de CORS</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; background-color: #f4f7f6; color: #333; }
        h1 { color: #2c3e50; }
        div { margin-bottom: 15px; background-color: #ffffff; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        input[type="text"] { width: 350px; padding: 10px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
        input[type="button"] { padding: 10px 20px; background-color: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
        input[type="button"]:hover { background-color: #2980b9; }
        .result { margin-top: 10px; padding: 10px; background-color: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 4px; display: none; }
    </style>
</head>
<body>
    <h1>Pruebas de Conectividad API</h1>

    <div>
        <label for="urlGreeting">GET Saludo:</label>
        <input type="text" id="urlGreeting" value="http://api.miservicio.com:8082/api/v1/greet" />
        <input type="button" id="btnGreeting" value="Enviar GET Saludo" />
        <div id="resGreeting" class="result"></div>
    </div>

    <div>
        <label for="urlDataInfo">GET Datos:</label>
        <input type="text" id="urlDataInfo" value="http://api.miservicio.com:8082/api/v1/data/info" />
        <input type="button" id="btnDataInfo" value="Enviar GET Datos" />
        <div id="resDataInfo" class="result"></div>
    </div>

    <div>
        <label for="urlUserRegister">POST Registro:</label>
        <input type="text" id="urlUserRegister" value="http://api.miservicio.com:8082/api/v1/user/register" />
        <input type="button" id="btnUserRegister" value="Enviar POST Registro" />
        <div id="resUserRegister" class="result"></div>
    </div>

    <div>
        <label for="urlProcessPayload">POST JSON:</label>
        <input type="text" id="urlProcessPayload" value="http://api.miservicio.com:8082/api/v1/process/payload" />
        <input type="button" id="btnProcessPayload" value="Enviar POST JSON" />
        <div id="resProcessPayload" class="result"></div>
    </div>

    <script type="text/javascript">
        $(function() {
            function showResult(elementId, message, isError = false) {
                const element = $(`#${elementId}`);
                element.text(message).css('color', isError ? 'red' : 'green').show();
            }

            $("#btnGreeting").click(function() {
                var endpointUrl = $("#urlGreeting").val();
                $.get({
                    url: endpointUrl,
                    data: { recipient: "UsuarioWeb" },
                    success: function(data) {
                        showResult('resGreeting', "Respuesta GET: " + data);
                    },
                    error: function(xhr, status, error) {
                        console.error("Error en GET Saludo:", status, error, xhr.responseText);
                        showResult('resGreeting', "Error en la solicitud. Revise la consola del navegador.", true);
                    }
                });
            });

            $("#btnDataInfo").click(function() {
                var endpointUrl = $("#urlDataInfo").val();
                $.get(endpointUrl, { paramId: "101", token: "secret_token_123" }, function(data) {
                    showResult('resDataInfo', "Respuesta GET Datos: " + data);
                }).fail(function(xhr, status, error) {
                    console.error("Error en GET Datos:", status, error, xhr.responseText);
                    showResult('resDataInfo', "Error en la solicitud. Revise la consola del navegador.", true);
                });
            });

            $("#btnUserRegister").click(function() {
                var endpointUrl = $("#urlUserRegister").val();
                $.ajax({
                    url: endpointUrl,
                    type: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({ username: "john.doe", passwordHash: "hashedpwd123" }),
                    dataType: 'json', // Espera respuesta JSON
                    success: function(data) {
                        showResult('resUserRegister', "Usuario registrado: " + JSON.stringify(data));
                    },
                    error: function(xhr, status, error) {
                        console.error("Error en POST Registro:", status, error, xhr.responseText);
                        showResult('resUserRegister', "Error en la solicitud. Revise la consola del navegador.", true);
                    }
                });
            });

            $("#btnProcessPayload").click(function() {
                var endpointUrl = $("#urlProcessPayload").val();
                $.ajax({
                    url: endpointUrl,
                    type: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({ key: "product_id", value: 456 }),
                    dataType: 'json',
                    success: function(data) {
                        showResult('resProcessPayload', "Payload procesado: " + JSON.stringify(data));
                        console.log("Respuesta completa de payload:", data);
                    },
                    error: function(xhr, status, error) {
                        console.error("Error en POST JSON:", status, error, xhr.responseText);
                        showResult('resProcessPayload', "Error en la solicitud. Revise la consola del navegador.", true);
                    }
                });
            });
        });
    </script>
</body>
</html>

Al ejecutar este frontend sin ninguna configuración CORS en el backend, el navegador bloqueará las solicitudes y la consola mostrará errores similares a "Cross-Origin Request Blocked".

Soluciones para Habilitar CORS en Spring Boot

Spring Boot ofrece varias formas de configurar y gestionar las políticas CORS para tus aplicaciones. A continuación, exploraremos las estrategias más comunes.

1. Implementación de WebMvcConfigurer

La forma más robusta y global de configurar CORS en una aplicación Spring Boot es implementar la interfaz WebMvcConfigurer y anular el método addCorsMappings. Esto permite una configuración centralizada para múltiples rutas o para toda la aplicación.

package com.example.app.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Clase de configuración para habilitar CORS globalmente en la aplicación Spring Boot.
 * Extiende WebMvcConfigurer para personalizar la configuración de MVC, incluyendo CORS.
 */
@Configuration
public class WebConfigCORS implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/v1/**") // Define las rutas a las que se aplica esta política CORS
                .allowedOrigins("http://localhost:8848", "http://otro.dominio.com") // Orígenes permitidos (frontend)
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Métodos HTTP permitidos
                .allowedHeaders("*") // Cabeceras permitidas en las solicitudes
                .allowCredentials(true) // Permite el envío de credenciales (cookies, encabezados de autorización)
                .maxAge(3600); // Duración de caché para las respuestas de pre-vuelo (en segundos)
    }
}

  • addMapping("/api/v1/**"): Configura qué rutas de tu backend deben permitir solicitudes de origen cruzado. Aquí, se aplica a todas las rutas que comienzan con /api/v1/.
  • allowedOrigins(...): Especifica los orígenes (dominios, protocolos y puertos) que están autorizados a realizar solicitudes. Puedes listar múltiples orígenes.
  • allowedMethods(...): Define los métodos HTTP (GET, POST, PUT, etc.) que se permitirán para las solicitudes de origen cruzado.
  • allowedHeaders("*"): Permite todas las cabeceras de solicitud. Se puede especificar una lista si se desea un control más granular.
  • allowCredentials(true): Es crucial si tu frontend necesita enviar cookies o cabeceras de autorización (como un token JWT) con las solicitudes de origen cruzado.
  • maxAge(3600): Configura por cuánto tiempo (en segundos) los navegadores pueden almacenar en caché las respuestas a las solicitudes "pre-vuelo" de CORS (solicitudes OPTIONS).

2. Uso de Filtros Servlet

Otra opción es implementar un filtro Servlet que intercepte todas las solicitudes y añada las cabeceras CORS necesarias. Hay dos enfoques principales:

Enfoque A: Filtro Servlet Personalizado

Puedes crear una clase que implemente javax.servlet.Filter y configurarla como un componente de Spring.

package com.example.app.filters;

import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Filtro Servlet personalizado para añadir cabeceras CORS manualmente.
 * Se ejecuta para todas las solicitudes y configura las respuestas HTTP.
 */
@Component
@Order(1) // Asegura que este filtro se ejecute muy temprano en la cadena de filtros
public class CustomCORSFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // Inicialización del filtro si es necesaria
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        // Recuperar el origen de la solicitud
        String clientOrigin = httpRequest.getHeader("Origin");

        // Configurar el origen permitido. Se recomienda especificar orígenes.
        // Para simplificar, se puede permitir cualquier origen (*) pero esto es menos seguro
        // y no compatible con allowCredentials=true para un uso práctico.
        // Aquí se muestra un ejemplo con un origen específico o una lista de permitidos.
        String allowedFrontendOrigin = "http://localhost:8848"; // O desde propiedades
        if (clientOrigin != null && clientOrigin.equals(allowedFrontendOrigin)) {
            httpResponse.setHeader("Access-Control-Allow-Origin", clientOrigin);
        } else {
            // Alternativamente, se podría denegar explícitamente o no añadir la cabecera
            // si el origen no está en la lista blanca.
            // httpResponse.setHeader("Access-Control-Allow-Origin", "null"); // O un origen por defecto
        }

        httpResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        httpResponse.setHeader("Access-Control-Max-Age", "3600"); // 1 hora de caché
        httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Custom-Header");
        httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); // Permitir envío de cookies/credenciales

        // Si es una solicitud OPTIONS (pre-vuelo), respondemos inmediatamente
        if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
            httpResponse.setStatus(HttpServletResponse.SC_OK);
            return;
        }

        chain.doFilter(servletRequest, servletResponse); // Continuar con la cadena de filtros
    }

    @Override
    public void destroy() {
        // Limpieza del filtro
    }
}

Enfoque B: Utilizando CorsFilter de Spring Framework

Spring Boot también proporciona un CorsFilter que se puede registrar como un bean. Este filtro es más idiomático de Spring y permite una configuración más declarativa.

package com.example.app.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * Configuración para registrar el CorsFilter de Spring Framework como un bean.
 * Proporciona una manera programática de configurar CORS.
 */
@Configuration
public class SpringCorsFilterConfig {

    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); // Permitir credenciales
        config.addAllowedOrigin("http://localhost:8848"); // Origen específico
        config.addAllowedHeader("*"); // Todas las cabeceras
        config.addAllowedMethod("*"); // Todos los métodos HTTP
        config.setMaxAge(3600L); // Caché de pre-vuelo por 1 hora

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/v1/**", config); // Aplica a /api/v1/**

        FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
        bean.setOrder(0); // Asegura que este filtro se ejecute primero
        return bean;
    }
}

3. Uso de la Anotación @CrossOrigin

Para un control más granular, Spring ofrece la anotación @CrossOrigin, que se puede aplicar a nivel de clase de controlador o a métodos individuales. Esto es ideal para escenarios donde solo ciertas API o métodos específicos requieren aceso de origen cruzado.

package com.example.app.api;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

// ... UserData y GenericRequestPayload definitions ...

/**
 * Controlador REST con configuración CORS a nivel de clase.
 * Permite que todos los métodos de este controlador manejen solicitudes de origen cruzado
 * desde el origen especificado.
 */
@CrossOrigin(
    origins = {"http://localhost:8848"}, // Define los orígenes permitidos
    methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE}, // Métodos HTTP
    allowCredentials = "true", // Permite credenciales como cookies
    maxAge = 3600 // Tiempo de caché para pre-vuelos
)
@RestController
@RequestMapping("/api/v1")
public class AppGatewayController {

    @GetMapping("/greet")
    public ResponseEntity<String> sendGreeting(@RequestParam String recipient) {
        return ResponseEntity.ok("Saludos, " + recipient + " desde el backend!");
    }

    // @CrossOrigin también puede aplicarse a nivel de método, anulando la configuración de la clase
    @CrossOrigin(origins = {"http://otro.dominio.com"}, maxAge = 1800)
    @GetMapping("/data/info")
    public ResponseEntity<String> retrieveSpecificData(@RequestParam String paramId, @RequestParam(required = false) String token) {
        return ResponseEntity.ok("Datos para ID: " + paramId + (token != null ? " con token: " + token : ""));
    }

    @PostMapping("/user/register")
    public ResponseEntity<UserData> processUserRegistration(@RequestBody UserData userDetails) {
        return ResponseEntity.ok(userDetails);
    }

    @PostMapping("/process/payload")
    public ResponseEntity<Map<String, Object>> handleJsonPayload(@RequestBody Map<String, Object> payload) {
        payload.put("status", "processed");
        payload.put("timestamp", System.currentTimeMillis());
        return ResponseEntity.ok(payload);
    }
}

  • La anotación puede aplicarse a la clase para afectar a todos sus métodos, o a métodos individuales para una configuración específica.
  • Los atributos como origins, methods, allowedHeaders, allowCredentials y maxAge funcionan de manera similar a la configuración en WebMvcConfigurer.

4. Configuración de un Proxy Inverso con Nginx

Una estrategia muy común, especialmente en entornos de producción, es evitar el problema de CORS por completo utilizando un proxy inverso como Nginx. El proxy hace que tanto el frontend como el backend parezcan estar en el mismo origen desde la perspectiva del navegador. El frontend realiza solicitudes al mismo dominio, y Nginx las reenvía internamente al servicio backend.

Si el frontend se sirve desde http://localhost:8848 y el back end está en http://api.miservicio.com:8082, podemos configurar Nginx para que, cuando el frontend haga una solicitud a /api/, Nginx la redirija al backend.

server {
    listen       8848; # El puerto donde Nginx escucha las solicitudes del navegador (el mismo que el frontend)
    server_name  localhost; # Dominio del servidor frontend

    # Configuración para servir los archivos estáticos de la aplicación frontend
    location / {
        root   /usr/share/nginx/html/frontend_app; # Ruta donde se encuentran los archivos HTML, CSS, JS del frontend
        index  index.html index.htm;
        try_files $uri $uri/ /index.html; # Para aplicaciones de una sola página (SPA)
    }

    # Configuración del proxy para el backend
    location /api/v1/ { # Cuando una solicitud del frontend vaya a /api/v1/...
        proxy_pass http://api.miservicio.com:8082/api/v1/; # Nginx la reenvía a esta URL interna del backend
        proxy_set_header Host $host; # Preserva la cabecera Host original
        proxy_set_header X-Real-IP $remote_addr; # Pasa la IP real del cliente
        proxy_set_header REMOTE-HOST $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Pasa la cadena de IPs de los proxies
        proxy_read_timeout 90s; # Tiempo de espera para la lectura de la respuesta del backend
        proxy_connect_timeout 60s; # Tiempo de espera para establecer conexión con el backend
    }

    # Páginas de error personalizadas
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

Con esta configuración de Nginx, el navegador solo ve interacciones con http://localhost:8848. Cuando solicita http://localhost:8848/api/v1/greet, Nginx lo intercede y lo redirige a http://api.miservicio.com:8082/api/v1/greet. Para el navegador, sigue siendo una solicitud al mismo origen, eliminando el problema de CORS.

Consideraciones Importantes

1. CORS y Credenciales (Cookies, Tokens de Autorización)

Si tu aplicación requiere el envío de cookies o cabeceras de autorización (como un token JWT) con las solicitudes de origen cruzado, es imprescindible configurar allowCredentials (o Access-Control-Allow-Credentials en filtros) a true. De lo contrario, las credenciales no se enviarán y la autenticación fallará. Además, si allowCredentials es true, Access-Control-Allow-Origin no puede ser el valor comodín (*); debes especificar explícitamente los orígenes permitidos.

Access-Control-Allow-Origin: http://localhost:8848
Access-Control-Allow-Credentials: true

Intentar usar "Access-Control-Allow-Origin: *" junto con "Access-Control-Allow-Credentials: true" generará un error en el navegador similar a:

The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.

O, más comúnmente:

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Esto significa que el origen debe ser explícito. Si necesitas permitir múltiples orígenes con credenciales, debes ganerar la cabecera Access-Control-Allow-Origin dinámicamente en tu backend, verificando el Origin de la solicitud entrante contra una lista blanca.

2. Solicitudes Pre-vuelo (Preflight Requests)

Para ciertas solicitudes de origen cruzado (ej. métodos distintos de GET/POST/HEAD, o con cabeceras personalizadas), el navegador envía una solicitud OPTIONS "pre-vuelo" antes de la solicitud real. El servidor debe responder a estas solicitudes OPTIONS con las cabeceras CORS adecuadas, incluyendo Access-Control-Allow-Methods y Access-Control-Allow-Headers, para informar al navegador qué tipo de solicitud real se permitirá. Las configuraciones de Spring Boot (WebMvcConfigurer, @CrossOrigin y CorsFilter) manejan estas solicitudes pre-vuelo automáticamente.

Etiquetas: Spring Boot CORS REST APIs WebMvcConfigurer Servlet Filters

Publicado el 9-16 13:56