/* * 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 relajación 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 tu script lea la respuesta.', 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 un valor: o un solo origen, o *. 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 * con pasos de más, y a diferencia de * también funciona con credenciales. ' + 'Reflejá solo después de validar contra una allowlist, y mandá siempre Vary: Origin — 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: text/plain, application/x-www-form-urlencoded y multipart/form-data — 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. application/json 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 nombres 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 * 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 content type 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 dos veces: 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 fetch 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 * está prohibido: el navegador exige un origen específico. ' + 'El razonamiento: * 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 + credentials: \'include\' 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 127.0.0.1:3001 en lugar de localhost:3001. CORS queda satisfecho y el fetch funciona — y la lista de cookies está vacía. ' + 'Son dos mecanismos distintos: CORS habla de orígenes y controla quién puede leer una respuesta. SameSite habla de sitios y controla si la cookie se adjunta siquiera. ' + 'localhost y 127.0.0.1 son la misma máquina pero sitios distintos, mientras que localhost:3000 y localhost:3001 son orígenes distintos del mismo 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 response.headers.get() 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, * expone todos los headers. Cómodo, y deja de funcionar en silencio el día que alguien agrega credentials: \'include\' — donde * 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: los headers CORS tienen que estar en todas las respuestas, incluidas las de error. ' + '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 fetch resuelve, response.ok es false, podés leer el status y el cuerpo del error, y podés debuggear el problema real. ' + 'Y ojo: fetch no 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 opaca: 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 script, no qué puede descargar el navegador. (Agregale crossorigin 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 respuesta. ' + '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.', }, ];