wandres.dev
NETWORK III · Cabeceras, caché y seguridad

Diagnosticar un CORS: la preflight y la cabecera que falta

Leer un fallo de origen cruzado en el panel de red y en el de incidencias, distinguir los seis modos de fallo, y saber exactamente qué cabecera pedirle al backend.

⏱ 20 min

El intercambio de recursos entre orígenes es el mecanismo que más horas de frontend consume por unidad de complejidad real, y la razón es que el navegador informa del fallo de una forma deliberadamente pobre: para no filtrar información sobre servidores ajenos, oculta la respuesta al código que la pidió. El resultado es un error genérico en la consola y un objeto vacío en catch. El panel de red, en cambio, sí ve la respuesta completa, y el panel de incidencias nombra el modo de fallo exacto. Con esas dos vistas, un CORS deja de ser un misterio y pasa a ser una cabecera concreta que le falta a una respuesta concreta.

🎯 Al terminar esta lección sabrás
  • Distinguir una petición simple de una que dispara comprobación previa y saber qué la dispara.
  • Leer en el panel la petición OPTIONS y las cabeceras de la respuesta a la comprobación previa.
  • Identificar cuál de los seis modos de fallo se está dando y qué cabecera lo arregla.
  • Explicar por qué las credenciales y el comodín son incompatibles y qué hacer en su lugar.

Qué dispara la comprobación previa

No todas las peticiones a otro origen se comprueban por adelantado. La distinción importa porque cambia por completo dónde hay que mirar: si no hay comprobación previa, el fallo está en la respuesta real; si la hay, casi siempre está en la respuesta a la comprobación.

Una petición no dispara comprobación previa si usa GET, HEAD o POST, si sus cabeceras están dentro de un conjunto muy corto que el navegador considera inofensivo, y si su Content-Type es uno de tres valores: application/x-www-form-urlencoded, multipart/form-data o text/plain. Cualquier desviación la dispara.

Eso significa que la causa más común de una comprobación previa inesperada es enviar JSON. Un POST con Content-Type: application/json no está en la lista de tipos permitidos y por tanto se comprueba antes. La segunda causa más común es una cabecera de autenticación propia. La tercera, un método que no sea de los tres simples: cualquier PUT, PATCH o DELETE la dispara siempre.

flowchart TB
a[Peticion a otro origen] --> b{Metodo simple y cabeceras simples}
b -->|Si| c[Sale la peticion real]
b -->|No| d[Sale OPTIONS de comprobacion previa]
d --> e{La respuesta autoriza origen metodo y cabeceras}
e -->|No| f[Bloqueada antes de salir la real]
e -->|Si| c
c --> g{La respuesta lleva Allow-Origin valido}
g -->|No| h[Respuesta bloqueada tras llegar]
g -->|Si| i[El codigo recibe la respuesta]
style a fill:#cba6f7,color:#11111b
style d fill:#f9e2af,color:#11111b
style f fill:#f38ba8,color:#11111b
style h fill:#f38ba8,color:#11111b
style c fill:#89b4fa,color:#11111b
style i fill:#a6e3a1,color:#11111b

La diferencia entre las dos ramas de fallo es operativamente enorme. Si la comprobación previa falla, la petición real nunca sale y el servidor no se entera de nada: buscarla en los registros del backend es perder el tiempo. Si falla la petición real, el servidor la procesó entera y sus efectos secundarios ocurrieron; solo la respuesta se descartó. Ese matiz explica el bug desconcertante de un POST que “falla” y sin embargo crea el registro dos veces.

Leer el fallo en los dos paneles

En el panel de red, lo primero es asegurarse de que la comprobación previa es visible. Aparece como una entrada propia con método OPTIONS, y algunos filtros la esconden. Al seleccionar la petición real, la pestaña de cabeceras enlaza directamente a su comprobación previa cuando la hubo, que es la forma rápida de saltar entre las dos.

En la comprobación previa hay que leer tres cabeceras de petición y tres de respuesta, y compararlas por pares:

El navegador pide El servidor debe responder Si no coincide
Origin Access-Control-Allow-Origin con ese origen exacto o * Origen no autorizado
Access-Control-Request-Method Access-Control-Allow-Methods incluyendo ese método Método no autorizado
Access-Control-Request-Headers Access-Control-Allow-Headers incluyendo todas Cabecera no autorizada

La comparación es literal y sin indulgencia. Un Access-Control-Allow-Origin con una barra final de más no coincide. Una cabecera personalizada que el cliente manda y que no está en la lista de permitidas bloquea la petición entera aunque el resto esté bien.

El panel de incidencias hace este trabajo por ti y conviene abrirlo siempre que aparezca un CORS: registra el fallo con un nombre concreto del modo de fallo en lugar del mensaje genérico de la consola, y esa etiqueta apunta directamente a la fila de la tabla anterior. Es la diferencia entre “algo de CORS” y “falta el método en la lista de permitidos de la comprobación previa”.

Los seis modos de fallo

Falta la cabecera de origen permitido. El servidor no emite Access-Control-Allow-Origin en absoluto. Suele significar que la ruta que estás llamando no pasa por el middleware de CORS, o que solo lo pasa en algunos métodos. Comprueba si otras rutas del mismo servidor sí funcionan.

El origen no coincide. El servidor emite un origen, pero no el tuyo. Es habitual en entornos de desarrollo donde la lista blanca solo tiene el dominio de producción, y también cuando cambias de puerto.

Comodín con credenciales. Si la petición lleva credenciales, el navegador rechaza un Access-Control-Allow-Origin: *. La respuesta tiene que repetir el origen exacto del solicitante y añadir Access-Control-Allow-Credentials: true. Es una restricción deliberada: un comodín con cookies permitiría a cualquier sitio hacer peticiones autenticadas en nombre del usuario. La consecuencia para el servidor es que tiene que leer el Origin de la petición, validarlo contra su lista y devolverlo, lo que a su vez obliga a emitir Vary: Origin para no envenenar cachés intermedias con la respuesta de otro origen.

Método o cabecera no autorizados. La comprobación previa responde bien pero su lista no incluye lo que la petición real va a usar. Es el modo más fácil de arreglar y el más fácil de diagnosticar con la tabla anterior.

La comprobación previa devuelve un estado de error. Muchos servidores responden 401 o 404 al OPTIONS porque el middleware de autenticación se ejecuta antes que el de CORS. La comprobación previa no lleva credenciales por diseño, así que un servidor que exige autenticación en todas las rutas rechazará siempre. La respuesta a OPTIONS tiene que ser un estado de éxito y salir antes de cualquier comprobación de identidad.

La respuesta llega pero no puedes leer una cabecera. Aquí no hay error: la petición funciona y el código recibe el cuerpo, pero response.headers.get() devuelve null para una cabecera que ves perfectamente en el panel. Es el comportamiento correcto: en respuestas de otro origen solo son legibles siete cabeceras. Para exponer más, el servidor tiene que listarlas en Access-Control-Expose-Headers. Es la causa de que la paginación por cabecera o un identificador de correlación no lleguen al cliente.

⚠️
Cuidado

El panel muestra el cuerpo de una respuesta bloqueada por CORS, y eso confunde a mucha gente: parece que la respuesta llegó bien y que el problema es del código. Es exactamente lo contrario. El panel forma parte del navegador y ve la respuesta cruda; el código de la página vive detrás de la política de origen y no la ve. Que tú la veas en el panel no significa nada sobre lo que recibió tu fetch.

Reproducir y aislar

Un CORS se diagnostica más rápido si se separa de la aplicación. Este fragmento lanza las dos peticiones por separado desde la consola de la página con problemas, y muestra qué pasó en cada fase.

// Diagnostico de CORS: comprueba la preflight y la peticion real por separado
(async (url, metodo = 'POST') => {
  const origen = location.origin;
  console.log('Origen que se va a enviar:', origen);

  // 1. La comprobacion previa, imitada a mano. Sale como peticion simple
  //    porque OPTIONS no es simple, asi que la lanzamos con no-cors para
  //    poder al menos ver si el servidor responde algo.
  try {
    const pre = await fetch(url, {
      method: 'OPTIONS',
      headers: { 'Access-Control-Request-Method': metodo }
    });
    console.log('OPTIONS respondio', pre.status, pre.statusText);
    for (const [k, v] of pre.headers) console.log('  ', k, '=', v);
  } catch (e) {
    console.warn('La comprobacion previa fallo. Mira la entrada OPTIONS en el panel de red.');
  }

  // 2. La peticion real como la hace la aplicacion.
  try {
    const r = await fetch(url, {
      method: metodo,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prueba: true })
    });
    console.log('La peticion real paso:', r.status);
    console.log('Cabeceras legibles desde JS:', [...r.headers.keys()]);
  } catch (e) {
    console.error('Bloqueada. Abre el panel de incidencias para el modo de fallo exacto.');
  }
})('https://httpbin.org/post');

Las dos líneas de salida que más información dan son las últimas de cada bloque. Las cabeceras que el OPTIONS devuelve son la lista literal de lo que el servidor autoriza, y compararla con lo que tu aplicación envía cierra el diagnóstico. Y la lista de cabeceras legibles desde JavaScript, cuando la petición sí funciona, es la comprobación de si necesitas Access-Control-Expose-Headers.

CORS no protege a tu servidor: protege al usuario de tu servidor

El malentendido que hay detrás de la mayoría de las malas decisiones sobre CORS es de dirección: la gente cree que es un mecanismo de seguridad que protege la API, y por tanto lo trata como un permiso que hay que apretar. Es al revés. La política de mismo origen protege al usuario, y CORS es el mecanismo por el que el servidor renuncia voluntariamente a parte de esa protección. La razón de que exista es concreta: el navegador envía automáticamente las cookies del usuario en las peticiones a un dominio, así que sin la política, cualquier página que visitaras podría hacer una petición autenticada a tu banco con tu sesión y leer la respuesta. La política no impide que la petición salga, impide que el código que la lanzó lea el resultado. Eso explica tres cosas que de otra forma parecen incoherentes. La primera: CORS no es una defensa contra nada que no sea un navegador. Un cliente cualquiera fuera del navegador ignora estas cabeceras por completo, porque no hay ningún usuario cuyas credenciales ambientales robar. Poner una lista de orígenes permitidos no protege una API de accesos automatizados; para eso está la autenticación. La segunda: abrir CORS a todo el mundo con un comodín no es peligroso para tu servidor si tu API no depende de credenciales ambientales. Una API pública que autentica con un token explícito en una cabecera puede llevar Access-Control-Allow-Origin: * sin problema, porque un atacante no obtiene ningún token por el hecho de que su página pueda hacer la petición. Lo que sí es peligroso es el comodín combinado con autenticación por cookie, y por eso la especificación directamente lo prohíbe. La tercera, y la más práctica: si estás peleando con CORS es porque hay dos orígenes, y muchas veces la solución correcta es que no los haya. Un proxy inverso que sirva la API bajo el mismo dominio que la aplicación elimina el problema entero, elimina la comprobación previa y su viaje de ida y vuelta, permite usar cookies con SameSite estricto en vez de aflojarlas, y quita una clase completa de bugs de configuración. Antes de negociar una lista de orígenes con el equipo de infraestructura, pregunta si el mismo equipo puede darte una ruta bajo tu propio dominio. Casi siempre es menos trabajo y siempre es más rápido en ejecución.