506 lines
28 KiB
JavaScript
506 lines
28 KiB
JavaScript
/*
|
|
* El catálogo de escenarios.
|
|
*
|
|
* Cada entrada describe una configuración del servidor + un request del cliente,
|
|
* y qué debería pasar. Las claves de `server` se convierten en query params de la
|
|
* URL de la API, así que el servidor se reconfigura en cada request: no hace falta
|
|
* reiniciar nada.
|
|
*
|
|
* expect: 'pass' el navegador le entrega la respuesta a JS
|
|
* 'fail' el navegador la bloquea (fetch rechaza)
|
|
* 'partial' el request funciona, pero algo sigue estando oculto
|
|
*/
|
|
|
|
window.GROUPS = [
|
|
{ id: 'baseline', title: '1 · La regla básica', blurb: 'Qué controla CORS realmente, y el único header que abre la puerta.' },
|
|
{ id: 'preflight', title: '2 · Mecánica del preflight', blurb: 'Qué hace que un request sea "simple", qué dispara el OPTIONS, y cómo falla un preflight.' },
|
|
{ id: 'credentials', title: '3 · Credenciales y comodines', blurb: 'Cookies, Authorization, y las reglas que cambian apenas aparecen las credenciales.' },
|
|
{ id: 'exposure', title: '4 · Leer la respuesta', blurb: 'Un request exitoso igual le esconde a JS casi todos los headers de respuesta.' },
|
|
{ id: 'failures', title: '5 · Cómo se rompe esto en la vida real', blurb: 'Los modos de falla que se comen una tarde entera.' },
|
|
];
|
|
|
|
window.SCENARIOS = [
|
|
/* ---------------------------------------------------------------- *
|
|
* 1 · La regla básica
|
|
* ---------------------------------------------------------------- */
|
|
{
|
|
id: 'same-origin',
|
|
group: 'baseline',
|
|
title: 'Request al mismo origen',
|
|
expect: 'pass',
|
|
target: 'same',
|
|
server: {},
|
|
client: { method: 'GET' },
|
|
why:
|
|
'La página y el endpoint comparten esquema + host + puerto, así que esto no es un request entre orígenes y CORS nunca entra en juego. ' +
|
|
'El servidor no manda ningún header CORS y aun así funciona. CORS no es una medida de seguridad que uno enciende: es una <em>relajación</em> de una restricción que solo aplica entre orígenes distintos.',
|
|
watch: 'Un solo request en el log. Sin OPTIONS, sin headers CORS.',
|
|
},
|
|
{
|
|
id: 'no-headers',
|
|
group: 'baseline',
|
|
title: 'Origen cruzado, el servidor no manda headers CORS',
|
|
expect: 'fail',
|
|
server: {},
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El comportamiento por defecto. El navegador mandó el request, el servidor lo ejecutó y respondió 200 — mirá el log. ' +
|
|
'Después el navegador se negó a entregarle la respuesta a JS porque no había Access-Control-Allow-Origin. ' +
|
|
'Esta es la diapositiva más importante de todas: CORS no impide el request, impide que <em>tu script lea la respuesta</em>.',
|
|
watch: 'El log muestra un 200. El fetch igual falló. Abrí DevTools → Network y vas a ver el cuerpo de la respuesta que el navegador se negó a darle a JS.',
|
|
},
|
|
{
|
|
id: 'wildcard',
|
|
group: 'baseline',
|
|
title: 'Access-Control-Allow-Origin: *',
|
|
expect: 'pass',
|
|
server: { acao: '*' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El martillo. Cualquier origen de internet puede leer esta respuesta. Está bien para datos genuinamente públicos, y mal para cualquier cosa que dependa del usuario — además, como muestra el grupo 3, el navegador no te va a dejar combinarlo con credenciales.',
|
|
watch: 'El mismo request de antes, ahora legible.',
|
|
},
|
|
{
|
|
id: 'exact-origin',
|
|
group: 'baseline',
|
|
title: 'Allow-Origin: un origen exacto',
|
|
expect: 'pass',
|
|
server: { acao: 'http://localhost:3000', vary: '1' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El valor está escrito literalmente con el origen de esta página. Esto es lo que querés en producción: una allowlist. ' +
|
|
'Notá que el servidor también manda Vary: Origin — en el escenario de reflexión se ve por qué importa.',
|
|
watch: 'Compará el header enviado con el origen de la página en la barra superior.',
|
|
},
|
|
{
|
|
id: 'wrong-origin',
|
|
group: 'baseline',
|
|
title: 'Allow-Origin: un origen distinto',
|
|
expect: 'fail',
|
|
server: { acao: 'http://evil.example.com' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El header está, pero nombra a otro, así que la verificación falla. El navegador compara el valor contra el origen que hizo el request con una comparación exacta de strings: no hay patrones, ni lógica de subdominios, ni nada inteligente.',
|
|
watch: 'El mensaje de error dice que el header "no coincide con el origen provisto".',
|
|
},
|
|
{
|
|
id: 'origin-list',
|
|
group: 'baseline',
|
|
title: 'Allow-Origin con una lista separada por comas',
|
|
expect: 'fail',
|
|
server: { acao: 'http://localhost:3000, http://localhost:4000' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'Un error muy común. Access-Control-Allow-Origin acepta <em>un</em> valor: o un solo origen, o <code>*</code>. Una lista es simplemente un valor inválido, aunque el origen correcto aparezca adentro. ' +
|
|
'Por eso los servidores reales mantienen la allowlist internamente y reflejan de vuelta un único origen que coincida.',
|
|
watch: 'Falla aunque localhost:3000 esté literalmente en el header.',
|
|
},
|
|
{
|
|
id: 'trailing-slash',
|
|
group: 'baseline',
|
|
title: 'Allow-Origin con barra al final',
|
|
expect: 'fail',
|
|
server: { acao: 'http://localhost:3000/' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'Un origen es esquema + host + puerto. No tiene path, así que no lleva barra final. Un solo carácter, y la comparación exacta de strings falla. Vale mostrarlo porque se copia de archivos de configuración todo el tiempo.',
|
|
watch: 'Leé los dos strings uno al lado del otro en el resultado.',
|
|
},
|
|
{
|
|
id: 'reflect-origin',
|
|
group: 'baseline',
|
|
title: 'Reflejar el header Origin de vuelta',
|
|
expect: 'pass',
|
|
server: { acao: 'echo' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El servidor copia el Origin que recibió dentro de Allow-Origin. Funciona para cualquiera, y ese es justamente el problema: reflejar sin condiciones es <code>*</code> con pasos de más, y a diferencia de <code>*</code> también funciona con credenciales. ' +
|
|
'Reflejá solo después de validar contra una allowlist, y mandá siempre <strong>Vary: Origin</strong> — sin eso, un caché compartido o una CDN puede guardar la respuesta del origen A y servírsela al origen B.',
|
|
watch: 'Esta corrida omite Vary: Origin a propósito. Compará con el escenario de origen exacto de arriba.',
|
|
},
|
|
|
|
/* ---------------------------------------------------------------- *
|
|
* 2 · Mecánica del preflight
|
|
* ---------------------------------------------------------------- */
|
|
{
|
|
id: 'simple-get',
|
|
group: 'preflight',
|
|
title: 'Request simple: un GET común',
|
|
expect: 'pass',
|
|
server: { acao: '*' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'GET, HEAD y POST con headers que estén en la safelist de CORS son "simples": el navegador los manda directamente y aplica la verificación CORS sobre la respuesta. Sin OPTIONS.',
|
|
watch: 'Exactamente una fila en el log.',
|
|
},
|
|
{
|
|
id: 'simple-post-text',
|
|
group: 'preflight',
|
|
title: 'Request simple: POST con text/plain',
|
|
expect: 'pass',
|
|
server: { acao: '*' },
|
|
client: { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'hola desde la página' },
|
|
why:
|
|
'Sigue siendo simple. Content-Type está en la safelist para exactamente tres valores: <code>text/plain</code>, <code>application/x-www-form-urlencoded</code> y <code>multipart/form-data</code> — los tres que un formulario HTML ya podía mandar antes de que CORS existiera. Esa es toda la lógica de la safelist: no hay preflight para requests que la web siempre pudo hacer.',
|
|
watch: 'Sin OPTIONS, y el servidor recibió tu body.',
|
|
},
|
|
{
|
|
id: 'preflight-json',
|
|
group: 'preflight',
|
|
title: 'POST con application/json → preflight',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'echo' },
|
|
client: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"hola":"mundo"}' },
|
|
why:
|
|
'Un carácter fuera de la safelist y te comprás un viaje extra. <code>application/json</code> no está en la safelist, así que el navegador primero pide permiso con OPTIONS y recién después manda el POST. ' +
|
|
'Por esto "en Postman funciona": Postman no es un navegador y nunca hace preflight.',
|
|
watch: 'DOS filas en el log: OPTIONS, y después POST. Fijate qué pidió el preflight.',
|
|
},
|
|
{
|
|
id: 'preflight-custom-header-blocked',
|
|
group: 'preflight',
|
|
title: 'Header propio, sin Allow-Headers',
|
|
expect: 'fail',
|
|
server: { acao: '*' },
|
|
client: { method: 'GET', headers: { 'X-Api-Key': 'abc123' } },
|
|
why:
|
|
'Cualquier header que pongas vos y no esté en la safelist dispara un preflight, y el preflight tiene que listarlo en Access-Control-Allow-Headers. ' +
|
|
'Lo interesante está en el log: el GET nunca pasó. El preflight falló, así que el navegador jamás mandó el request real. El servidor nunca vio tu API key.',
|
|
watch: 'Una sola fila — el OPTIONS. No hay GET. Así se distingue una falla de preflight de una falla de respuesta.',
|
|
},
|
|
{
|
|
id: 'preflight-custom-header-ok',
|
|
group: 'preflight',
|
|
title: 'Header propio, con Allow-Headers reflejado',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'echo' },
|
|
client: { method: 'GET', headers: { 'X-Api-Key': 'abc123' } },
|
|
why:
|
|
'El servidor refleja Access-Control-Request-Headers como Access-Control-Allow-Headers, así que el navegador sigue adelante. Notá que el preflight llevó solo los <em>nombres</em> de los headers, nunca los valores. El preflight es una pregunta de permiso, no una copia del request.',
|
|
watch: 'Dos filas. El log del OPTIONS muestra los nombres pedidos; el del GET muestra el valor llegando.',
|
|
},
|
|
{
|
|
id: 'preflight-authorization',
|
|
group: 'preflight',
|
|
title: 'Header Authorization → preflight',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'authorization' },
|
|
client: { method: 'GET', headers: { Authorization: 'Bearer token-xyz' } },
|
|
why:
|
|
'Authorization no está en la safelist, así que cada llamada autenticada entre orígenes cuesta un preflight. Y tiene que nombrarse explícitamente: con credenciales de por medio, algunos navegadores no aceptan <code>*</code> acá.',
|
|
watch: 'Dos filas; el servidor reporta el header Authorization como presente.',
|
|
},
|
|
{
|
|
id: 'preflight-delete-no-methods',
|
|
group: 'preflight',
|
|
title: 'DELETE sin Allow-Methods',
|
|
expect: 'fail',
|
|
server: { acao: '*' },
|
|
client: { method: 'DELETE' },
|
|
why:
|
|
'DELETE no es un método de la safelist, así que el preflight tiene que listarlo en Access-Control-Allow-Methods. Falta el header, se bloquea el preflight, y el DELETE nunca llega al servidor.',
|
|
watch: 'Solo OPTIONS. Tu recurso nunca fue tocado.',
|
|
},
|
|
{
|
|
id: 'preflight-delete-ok',
|
|
group: 'preflight',
|
|
title: 'DELETE con Allow-Methods',
|
|
expect: 'pass',
|
|
server: { acao: '*', acam: 'GET,POST,PUT,PATCH,DELETE' },
|
|
client: { method: 'DELETE' },
|
|
why:
|
|
'Ahora el método está permitido. Allow-Methods responde "qué métodos puede usar este origen sobre esta URL", y va en la respuesta del preflight — ponerlo en la respuesta real no hace absolutamente nada.',
|
|
watch: 'OPTIONS, y después DELETE.',
|
|
},
|
|
{
|
|
id: 'preflight-post-implicit',
|
|
group: 'preflight',
|
|
title: 'POST con preflight y sin ningún Allow-Methods',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'echo' },
|
|
client: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}' },
|
|
why:
|
|
'Un detalle que confunde: GET, HEAD y POST están siempre permitidos, así que un preflight disparado por el <em>content type</em> pasa sin Allow-Methods. ' +
|
|
'El preflight hizo falta por el header, no por el método. No te apoyes en esto en configuración real: listá tus métodos y el comportamiento deja de ser sorprendente.',
|
|
watch: 'Dos filas, sin Allow-Methods, y aun así funciona.',
|
|
},
|
|
{
|
|
id: 'preflight-maxage',
|
|
group: 'preflight',
|
|
title: 'Max-Age: cachear el preflight',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'echo', acma: '120' },
|
|
client: { method: 'GET', headers: { 'X-Api-Key': 'cached' } },
|
|
stableUrl: true,
|
|
why:
|
|
'Access-Control-Max-Age le dice al navegador cuánto tiempo puede reutilizar el resultado de este preflight para la misma URL, método y headers. ' +
|
|
'Corré este escenario <strong>dos veces</strong>: la primera hace OPTIONS + GET, la segunda hace solo GET. En una API conversadora, esa es la diferencia entre uno y dos viajes por llamada.',
|
|
watch: 'Correlo de nuevo. El OPTIONS desaparece. (Los navegadores topean el Max-Age: Chrome en 2 horas, Firefox en 24.)',
|
|
},
|
|
{
|
|
id: 'preflight-slow',
|
|
group: 'preflight',
|
|
title: 'Cuánto cuesta un preflight',
|
|
expect: 'pass',
|
|
server: { acao: '*', acah: 'echo', delay: '400' },
|
|
client: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}' },
|
|
why:
|
|
'El mismo request de antes con 400 ms de latencia en el servidor. El tiempo del resultado se duplica, más o menos, porque pagaste dos viajes. Este es el argumento práctico a favor de Max-Age, y en contra de inventar headers propios a la ligera.',
|
|
watch: 'Compará el tiempo con el de los escenarios sin preflight.',
|
|
},
|
|
|
|
/* ---------------------------------------------------------------- *
|
|
* 3 · Credenciales y comodines
|
|
* ---------------------------------------------------------------- */
|
|
{
|
|
id: 'cookie-no-credentials',
|
|
group: 'credentials',
|
|
title: 'Hay cookies, pero credentials: omit',
|
|
expect: 'pass',
|
|
server: { acao: '*' },
|
|
client: { method: 'GET', credentials: 'omit' },
|
|
needsCookie: true,
|
|
why:
|
|
'Tenés cookies puestas en el origen de la API, y el servidor no recibió ninguna. Por defecto <code>fetch</code> no adjunta credenciales en requests entre orígenes: hay que pedirlo. ' +
|
|
'La mitad de los bugs de "¿por qué me desloguea?" en una SPA son esta línea.',
|
|
watch: 'El servidor reporta la lista de cookies vacía.',
|
|
},
|
|
{
|
|
id: 'credentials-wildcard',
|
|
group: 'credentials',
|
|
title: 'credentials: include + Allow-Origin: *',
|
|
expect: 'fail',
|
|
server: { acao: '*', acac: 'true' },
|
|
client: { method: 'GET', credentials: 'include' },
|
|
needsCookie: true,
|
|
why:
|
|
'La regla con la que choca todo el mundo. En modo credenciales <code>*</code> está prohibido: el navegador exige un origen específico. ' +
|
|
'El razonamiento: <code>*</code> significa "cualquiera puede leer esto", y una respuesta calculada a partir de las cookies de alguien nunca puede ser legible por cualquiera. El servidor tiene que nombrar en quién confía.',
|
|
watch: 'El error menciona el comodín explícitamente. Y ojo: el servidor SÍ recibió las cookies y SÍ ejecutó el request.',
|
|
},
|
|
{
|
|
id: 'credentials-no-acac',
|
|
group: 'credentials',
|
|
title: 'credentials: include, origen exacto, sin Allow-Credentials',
|
|
expect: 'fail',
|
|
server: { acao: 'echo', vary: '1' },
|
|
client: { method: 'GET', credentials: 'include' },
|
|
needsCookie: true,
|
|
why:
|
|
'El origen exacto es necesario pero no suficiente. Para un request con credenciales el servidor además tiene que dar el consentimiento con Access-Control-Allow-Credentials: true. Dos interruptores distintos, los dos obligatorios.',
|
|
watch: 'Las cookies llegaron al servidor; la respuesta igual fue bloqueada.',
|
|
},
|
|
{
|
|
id: 'credentials-ok',
|
|
group: 'credentials',
|
|
title: 'credentials: include, bien hecho',
|
|
expect: 'pass',
|
|
server: { acao: 'echo', acac: 'true', vary: '1' },
|
|
client: { method: 'GET', credentials: 'include' },
|
|
needsCookie: true,
|
|
why:
|
|
'Origen exacto (reflejado desde una allowlist, en código real) + Allow-Credentials: true + <code>credentials: \'include\'</code> en el cliente. Los tres, o nada. ' +
|
|
'Las cookies llegaron al servidor y la respuesta volvió legible.',
|
|
watch: 'El servidor lista cors_demo_lax y cors_demo_strict.',
|
|
},
|
|
{
|
|
id: 'credentials-preflight-missing-acac',
|
|
group: 'credentials',
|
|
title: 'Allow-Credentials en la respuesta pero no en el preflight',
|
|
expect: 'fail',
|
|
server: { acao: 'echo', acac: 'true', acah: 'echo', pfacac: '0' },
|
|
client: { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}' },
|
|
needsCookie: true,
|
|
why:
|
|
'El preflight también es un request con credenciales, y necesita su propio Allow-Credentials. Acá la respuesta real está configurada perfecta, pero a la respuesta del OPTIONS le falta el header, así que el request muere antes de salir. ' +
|
|
'Resultado frecuente de middleware de CORS hecho a mano que solo decora las respuestas que no son OPTIONS.',
|
|
watch: 'Solo OPTIONS. El POST nunca ocurrió, aunque su configuración era correcta.',
|
|
},
|
|
{
|
|
id: 'credentials-wildcard-headers',
|
|
group: 'credentials',
|
|
title: 'Allow-Headers: * con credenciales',
|
|
expect: 'fail',
|
|
server: { acao: 'echo', acac: 'true', acah: '*' },
|
|
client: { method: 'GET', credentials: 'include', headers: { 'X-Api-Key': 'abc' } },
|
|
needsCookie: true,
|
|
why:
|
|
'En modo credenciales todo comodín deja de ser comodín y se lee literalmente — como un header que realmente se llama "*". Lo mismo vale para Allow-Methods y Expose-Headers. Si usás credenciales, enumerás todo.',
|
|
watch: 'Compará con el mismo escenario sin credenciales, donde * funciona perfecto.',
|
|
},
|
|
{
|
|
id: 'samesite-cross-site',
|
|
group: 'credentials',
|
|
title: 'CORS pasa, pero las cookies no viajan (SameSite)',
|
|
expect: 'partial',
|
|
target: 'cross-site',
|
|
server: { acao: 'echo', acac: 'true', vary: '1' },
|
|
client: { method: 'GET', credentials: 'include' },
|
|
needsCookie: true,
|
|
why:
|
|
'Idéntico al escenario de credenciales que funciona, pero apuntando a <code>127.0.0.1:3001</code> en lugar de <code>localhost:3001</code>. CORS queda satisfecho y el fetch funciona — y la lista de cookies está vacía. ' +
|
|
'Son dos mecanismos distintos: <strong>CORS</strong> habla de orígenes y controla quién puede leer una respuesta. <strong>SameSite</strong> habla de sitios y controla si la cookie se adjunta siquiera. ' +
|
|
'<code>localhost</code> y <code>127.0.0.1</code> son la misma máquina pero sitios distintos, mientras que <code>localhost:3000</code> y <code>localhost:3001</code> son orígenes distintos del <em>mismo</em> sitio — por eso la cookie viaja entre puertos pero no entre hostnames.',
|
|
watch: 'El fetch funciona, las cookies vacías. Tener CORS bien no te consigue la cookie.',
|
|
},
|
|
|
|
/* ---------------------------------------------------------------- *
|
|
* 4 · Leer la respuesta
|
|
* ---------------------------------------------------------------- */
|
|
{
|
|
id: 'expose-missing',
|
|
group: 'exposure',
|
|
title: 'Leer X-Total-Count sin Expose-Headers',
|
|
expect: 'partial',
|
|
server: { acao: '*' },
|
|
client: { method: 'GET', readHeaders: ['X-Total-Count', 'X-Request-Id', 'Content-Type', 'Cache-Control'] },
|
|
why:
|
|
'El request funcionó, el header viaja por la red (miralo en DevTools), y <code>response.headers.get()</code> devuelve null. ' +
|
|
'En una respuesta entre orígenes, JS puede leer por defecto solo siete headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified y Pragma. Todo el resto se filtra del objeto Headers. ' +
|
|
'Este es el que hace creer que el servidor está roto. Los totales de paginación, los headers de rate limit y Location viven todos acá.',
|
|
watch: 'Content-Type y Cache-Control se leen bien; X-Total-Count es null.',
|
|
},
|
|
{
|
|
id: 'expose-listed',
|
|
group: 'exposure',
|
|
title: 'Expose-Headers los nombra',
|
|
expect: 'pass',
|
|
server: { acao: '*', aceh: 'X-Total-Count, X-Request-Id' },
|
|
client: { method: 'GET', readHeaders: ['X-Total-Count', 'X-Request-Id', 'X-Powered-By'] },
|
|
why:
|
|
'Access-Control-Expose-Headers es una allowlist explícita de headers de respuesta que JS puede leer. Va en la respuesta real, no en el preflight. Notá que X-Powered-By sigue oculto: solo pasa lo que listás.',
|
|
watch: 'Dos de los tres ahora legibles.',
|
|
},
|
|
{
|
|
id: 'expose-wildcard',
|
|
group: 'exposure',
|
|
title: 'Expose-Headers: * (sin credenciales)',
|
|
expect: 'pass',
|
|
server: { acao: '*', aceh: '*' },
|
|
client: { method: 'GET', readHeaders: ['X-Total-Count', 'X-Request-Id', 'X-Powered-By'] },
|
|
why:
|
|
'Para requests sin credenciales, <code>*</code> expone todos los headers. Cómodo, y deja de funcionar en silencio el día que alguien agrega <code>credentials: \'include\'</code> — donde <code>*</code> se toma literalmente.',
|
|
watch: 'Los tres legibles. Ahora imaginate el escenario que sigue.',
|
|
},
|
|
{
|
|
id: 'expose-wildcard-credentials',
|
|
group: 'exposure',
|
|
title: 'Expose-Headers: * con credenciales',
|
|
expect: 'partial',
|
|
server: { acao: 'echo', acac: 'true', aceh: '*', vary: '1' },
|
|
client: { method: 'GET', credentials: 'include', readHeaders: ['X-Total-Count', 'X-Request-Id'] },
|
|
needsCookie: true,
|
|
why:
|
|
'La misma configuración del servidor que arriba más credenciales, y los headers desaparecen otra vez. El request sigue funcionando, así que no hay error, no hay log: simplemente un valor se vuelve null en algún lugar profundo de tu código. Esta es la variante más molesta de la regla del comodín.',
|
|
watch: 'Nulls, sin ningún error en ninguna parte.',
|
|
},
|
|
|
|
/* ---------------------------------------------------------------- *
|
|
* 5 · Cómo se rompe esto en la vida real
|
|
* ---------------------------------------------------------------- */
|
|
{
|
|
id: 'error-without-cors',
|
|
group: 'failures',
|
|
title: 'Un 500 que se presenta como error de CORS',
|
|
expect: 'fail',
|
|
server: { acao: '*', status: '500', errnocors: '1' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'El endpoint explotó. El camino de error devolvió 500 sin pasar por el middleware de CORS, así que el navegador bloqueó la respuesta y lo único que llega a la consola es un mensaje de CORS. ' +
|
|
'Te pasás una hora tocando configuración de CORS por lo que en realidad era un null pointer en un controlador. La regla: <strong>los headers CORS tienen que estar en todas las respuestas, incluidas las de error.</strong> ' +
|
|
'Cuando veas un error de CORS inexplicable, lo primero es mirar el status real en la pestaña Network.',
|
|
watch: 'El log cuenta la verdad: status 500. El navegador te dijo "CORS".',
|
|
},
|
|
{
|
|
id: 'error-with-cors',
|
|
group: 'failures',
|
|
title: 'El mismo 500, con headers CORS',
|
|
expect: 'pass',
|
|
server: { acao: '*', status: '500' },
|
|
client: { method: 'GET' },
|
|
why:
|
|
'La misma falla, con los headers presentes. Ahora <code>fetch</code> resuelve, <code>response.ok</code> es false, podés leer el status y el cuerpo del error, y podés debuggear el problema real. ' +
|
|
'Y ojo: fetch <em>no</em> rechaza con 4xx ni 5xx. Rechaza con fallas de red y de CORS. Esa distinción es la que te permite diferenciarlas desde el código.',
|
|
watch: 'El mismo 500 en el log, una experiencia de desarrollo completamente distinta.',
|
|
},
|
|
{
|
|
id: 'preflight-401',
|
|
group: 'failures',
|
|
title: 'El middleware de auth responde el preflight con 401',
|
|
expect: 'fail',
|
|
server: { acao: '*', acah: 'authorization', pfstatus: '401' },
|
|
client: { method: 'GET', headers: { Authorization: 'Bearer token-xyz' } },
|
|
why:
|
|
'Tu capa de autenticación está adelante de todo y rechaza el request OPTIONS, que por supuesto no lleva header Authorization: el navegador nunca pone credenciales en un preflight. ' +
|
|
'El preflight tiene que responder 2xx, y tiene que ser alcanzable sin autenticación. Montá CORS antes que auth, y dejá pasar el OPTIONS.',
|
|
watch: 'El error menciona que el preflight no fue exitoso. El GET nunca corrió.',
|
|
},
|
|
{
|
|
id: 'preflight-no-cors-headers',
|
|
group: 'failures',
|
|
title: 'El preflight responde 204 pero sin headers CORS',
|
|
expect: 'fail',
|
|
server: { acao: '*', acah: 'echo', pfcors: '0' },
|
|
client: { method: 'GET', headers: { 'X-Api-Key': 'abc' } },
|
|
why:
|
|
'Un preflight 2xx no alcanza: tiene que llevar los headers CORS. Los frameworks que tienen una ruta genérica de "respondé 204 a OPTIONS" producen exactamente esto, y la configuración perfecta del endpoint real nunca llega a importar.',
|
|
watch: 'Un 204 exitoso en el log, y aun así bloqueado.',
|
|
},
|
|
{
|
|
id: 'preflight-redirect',
|
|
group: 'failures',
|
|
title: 'El preflight recibe un redirect',
|
|
expect: 'fail',
|
|
server: { acah: 'echo', pfredirect: '1' },
|
|
client: { method: 'GET', headers: { 'X-Api-Key': 'abc' } },
|
|
why:
|
|
'Los redirects no están permitidos en un preflight, punto. Casi siempre lo causa un redirect de HTTP a HTTPS, un normalizador de barra final, o un redirect de login que atrapa al OPTIONS. Arreglá la URL que llama tu cliente, no el redirect.',
|
|
watch: '302 en el log, falla dura en el navegador.',
|
|
},
|
|
{
|
|
id: 'no-cors-mode',
|
|
group: 'failures',
|
|
title: 'mode: \'no-cors\' — la solución falsa',
|
|
expect: 'partial',
|
|
server: {},
|
|
client: { method: 'GET', mode: 'no-cors' },
|
|
why:
|
|
'Lo sugieren todo el tiempo en Stack Overflow, y nunca hace lo que la gente quiere. Obtenés una respuesta <em>opaca</em>: status 0, sin headers, cuerpo ilegible. No podés leer los datos: lo único que lograste es que el navegador deje de quejarse. ' +
|
|
'Solo sirve para casos genuinos de disparar y olvidarse (una baliza, calentar un caché). Si necesitás los datos, el servidor tiene que mandar el header. No existe arreglo del lado del cliente para CORS.',
|
|
watch: 'type: "opaque", status 0, cuerpo vacío — y el servidor registró un 200 normal.',
|
|
},
|
|
{
|
|
id: 'no-cors-header-dropped',
|
|
group: 'failures',
|
|
title: 'no-cors descarta tus headers en silencio',
|
|
expect: 'partial',
|
|
server: {},
|
|
client: { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/json', 'X-Api-Key': 'abc' }, body: '{"a":1}' },
|
|
why:
|
|
'En modo no-cors solo sobreviven los headers de la safelist, así que el navegador descarta calladito Content-Type: application/json y X-Api-Key en lugar de hacer preflight. El servidor ve un request distinto del que vos escribiste. La mutación silenciosa es peor que un error.',
|
|
watch: 'El servidor reporta el content type que realmente recibió, y ningún X-Api-Key.',
|
|
},
|
|
{
|
|
id: 'img-tag',
|
|
group: 'failures',
|
|
title: '<img>: sin CORS, sin Origin, sin problema',
|
|
expect: 'special',
|
|
special: 'img',
|
|
why:
|
|
'Las imágenes, los scripts, las hojas de estilo y los iframes vienen cargándose entre orígenes desde 1995 y no necesitan CORS. La imagen de abajo se renderiza desde :3001 sin ningún header CORS, y el request ni siquiera lleva header Origin. ' +
|
|
'CORS gobierna qué puede leer el <em>script</em>, no qué puede descargar el navegador. (Agregale <code>crossorigin</code> a la etiqueta y las reglas de CORS sí aplican: así es como se leen los píxeles de un canvas.)',
|
|
watch: 'El log muestra un request sin Origin.',
|
|
},
|
|
{
|
|
id: 'form-post',
|
|
group: 'failures',
|
|
title: 'POST de formulario entre orígenes: CORS no te protege de CSRF',
|
|
expect: 'special',
|
|
special: 'form',
|
|
why:
|
|
'Esto postea un formulario a :3001 desde esta página, entre orígenes, con las cookies adjuntas, y el servidor lo procesa. Sin un solo header CORS en ningún lado. ' +
|
|
'No se bloqueó nada porque enviar un formulario es una navegación, no una lectura desde un script: el navegador solo retiene la <em>respuesta</em>. ' +
|
|
'Así que CORS nunca te protegió de requests entre orígenes que modifican estado. Para eso están las cookies SameSite y los tokens CSRF. Si tu clase se tiene que llevar una sola cosa además de la regla del comodín, que sea esta.',
|
|
watch: 'El log muestra un POST con tus cookies adjuntas y una respuesta 200.',
|
|
},
|
|
];
|