commit 4b4b45858461fef46711cec25e65ecd18532a201 Author: Matias Date: Thu Oct 1 13:02:44 2026 -0300 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..57ec6c0 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# CORS classroom + +A live playground for teaching CORS. One zero-dependency Node file starts three +origins; the page fires real cross-origin requests and shows the browser's verdict +next to the server's own log of what it actually received. + +```bash +node server.js # or: npm start +open http://localhost:3000 +``` + +Then, in this order: + +1. Open DevTools → Network and leave it visible. Every "blocked" response is still + there, which is the point of the first scenario. +2. Click **Set cookies on API** once, so the credentials scenarios have cookies to send. +3. Work down the six groups, or hit **Run all** and talk through the results. + +## What's running + +| Origin | Role | +| --- | --- | +| `http://localhost:3000` | the page. Also serves `/same-origin/api` and the log stream — both same-origin, so neither is affected by CORS. | +| `http://localhost:3001` | the API. A different **origin** from the page, but the same **site**, so cookies still travel there. | +| `http://127.0.0.1:3001` | the same listener reached as a different **site**, which is how the SameSite demo works. | + +The API builds its CORS headers from query parameters on every request, so each +scenario reconfigures the server as it runs. Nothing needs restarting, and you can +hand-edit any URL live in front of the class. + +| Param | Effect | +| --- | --- | +| `acao=*` / `echo` / literal | `Access-Control-Allow-Origin` (omit the param to send no header at all) | +| `acac=true` | `Access-Control-Allow-Credentials` | +| `acam=` / `acah=` | `Allow-Methods` / `Allow-Headers`; `echo` reflects what the preflight asked for | +| `aceh=` | `Access-Control-Expose-Headers` | +| `acma=60` | `Access-Control-Max-Age` (preflight cache) | +| `vary=1` | adds `Vary: Origin` | +| `pfstatus=401` | status code for the OPTIONS preflight | +| `pfcors=0` | answer the preflight with no CORS headers | +| `pfacao=0` / `pfacac=0` | omit that header on the preflight only | +| `pfredirect=1` | make the preflight respond 302 | +| `status=500` | status of the actual response | +| `errnocors=1` | drop CORS headers when status ≥ 400 — the classic middleware-order bug | +| `delay=400` | artificial latency, to show what a preflight costs | + +## The six groups + +1. **The basic rule** — CORS only applies cross-origin, and it gates reading the + response, not sending the request. Includes the ways `Allow-Origin` is commonly + written wrong: a comma-separated list, a trailing slash, unconditional reflection. +2. **Preflight mechanics** — what makes a request simple, what triggers OPTIONS, + why a failed preflight means the real request never reaches your server, and + `Max-Age`. The `Max-Age` card is worth running twice: the second run has no OPTIONS. +3. **Credentials and wildcards** — the `*`-with-credentials rule, `Allow-Credentials` + on the preflight as well as the response, and a cross-site scenario where CORS + passes but the cookie still isn't sent, to separate CORS from SameSite. +4. **Reading the response** — a successful request still hides every response header + outside the seven-header safelist, and `Expose-Headers: *` silently stops working + once credentials are involved. +5. **How this breaks in real life** — a 500 that reports itself as a CORS error, auth + middleware answering the preflight with 401, `mode: 'no-cors'` and what it really + does, and a cross-origin form POST that succeeds — the CORS-is-not-CSRF-protection + point. +6. **Free playground** — every knob as a form, with both preview panels updating as + you type, for when someone asks "but what if…". + +## Files + +- `server.js` — the whole backend, ~330 lines of plain Node, written to be read out loud. +- `public/scenarios.js` — the scenario catalogue as data: config, expectation, and the + teaching note for each. Add your own here. +- `public/app.js` — the runner: predicts whether a request will be preflighted, fires it, + and renders the browser verdict against the server log. +- `public/_selftest.html` — not linked from the page. Open + `http://localhost:3000/_selftest.html` to run every scenario and check that the + browser still agrees with the expectation written in the catalogue. Useful after + you edit scenarios, and for confirming behaviour in a different browser. + +All 37 fetch scenarios were verified against Chrome: each one behaves exactly as its +note describes. diff --git a/package.json b/package.json new file mode 100644 index 0000000..beac0e6 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "cors-classroom", + "version": "1.0.0", + "private": true, + "description": "A live CORS playground for teaching: one zero-dependency Node server, three origins, every CORS configuration as a runnable scenario.", + "scripts": { + "start": "node server.js" + } +} diff --git a/public/_selftest.html b/public/_selftest.html new file mode 100644 index 0000000..05cdee6 --- /dev/null +++ b/public/_selftest.html @@ -0,0 +1,33 @@ +selftest
running
+ + diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..4aa83bb --- /dev/null +++ b/public/app.js @@ -0,0 +1,697 @@ +/* + * The runner. Builds a card per scenario, fires the real request, and lines up + * three things side by side: what the server was told to send, what the browser + * let JavaScript see, and what the server actually logged. + */ + +'use strict'; + +/* ------------------------------------------------------------------ * + * Origins + * ------------------------------------------------------------------ */ + +const PAGE = location.origin; +const HOST = location.hostname; +const OTHER_HOST = HOST === 'localhost' ? '127.0.0.1' : 'localhost'; +const API = `${location.protocol}//${HOST}:3001`; // different origin, same site +const CROSS_SITE = `${location.protocol}//${OTHER_HOST}:3001`; // different origin AND site + +const TARGETS = { + api: { base: API + '/api/data', label: 'cross-origin API' }, + same: { base: PAGE + '/same-origin/api', label: 'same origin' }, + 'cross-site': { base: CROSS_SITE + '/api/data', label: 'cross-site API' }, +}; + +/* ------------------------------------------------------------------ * + * Preflight prediction (the same rules the browser applies) + * ------------------------------------------------------------------ */ + +const SAFELISTED_HEADERS = new Set([ + 'accept', 'accept-language', 'content-language', 'content-type', 'range', +]); +const SAFELISTED_CONTENT_TYPES = new Set([ + 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain', +]); +const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']); + +function preflightReason(client) { + const method = (client.method || 'GET').toUpperCase(); + if (client.mode === 'no-cors') return null; // no-cors nunca hace preflight: descarta lo que no puede mandar + if (!SAFELISTED_METHODS.has(method)) return `el método ${method} no está en la safelist`; + for (const [name, value] of Object.entries(client.headers || {})) { + const n = name.toLowerCase(); + if (!SAFELISTED_HEADERS.has(n)) return `el header ${name} no está en la safelist`; + if (n === 'content-type') { + const bare = String(value).split(';')[0].trim().toLowerCase(); + if (!SAFELISTED_CONTENT_TYPES.has(bare)) return `Content-Type: ${bare} no está en la safelist`; + } + } + return null; +} + +/* ------------------------------------------------------------------ * + * Rendering the server's configuration as the headers it will send + * ------------------------------------------------------------------ */ + +const HEADER_LABELS = [ + ['acao', 'Access-Control-Allow-Origin'], + ['acac', 'Access-Control-Allow-Credentials'], + ['aceh', 'Access-Control-Expose-Headers'], +]; +const PREFLIGHT_LABELS = [ + ['acam', 'Access-Control-Allow-Methods'], + ['acah', 'Access-Control-Allow-Headers'], + ['acma', 'Access-Control-Max-Age'], +]; + +const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + +function headerLines(server, phase, client) { + const out = []; + const isPreflight = phase === 'preflight'; + + if (isPreflight && server.pfredirect === '1') { + return ['302 Found', 'Location: /api/data?…']; + } + + const status = isPreflight ? server.pfstatus || '204' : server.status || '200'; + const statusText = { 200: 'OK', 204: 'No Content', 401: 'Unauthorized', 500: 'Internal Server Error' }[status] || ''; + const bad = Number(status) >= 400; + out.push(`${bad ? '' : ''}${status} ${statusText}${bad ? '' : ''}`); + + const suppressed = + (isPreflight && server.pfcors === '0') || + (!isPreflight && server.errnocors === '1' && Number(status) >= 400); + + if (suppressed) { + out.push('(ningún header CORS)'); + return out; + } + + for (const [key, label] of HEADER_LABELS) { + if (server[key] === undefined) continue; + if (isPreflight && key === 'acao' && server.pfacao === '0') continue; + if (isPreflight && key === 'acac' && server.pfacac === '0') continue; + if (!isPreflight && key === 'acam') continue; + let value = server[key]; + if (value === 'echo') value = key === 'acao' ? `${PAGE} (reflejado)` : value; + out.push(`${label}: ${value}`); + } + if (server.vary === '1') out.push('Vary: Origin'); + + if (isPreflight) { + for (const [key, label] of PREFLIGHT_LABELS) { + if (server[key] === undefined) continue; + let value = server[key]; + if (value === 'echo') { + const echoed = key === 'acah' + ? Object.keys(client.headers || {}).map((h) => h.toLowerCase()).join(', ') + : (client.method || 'GET'); + value = `${echoed || '(no pidió nada)'} (reflejado)`; + } + out.push(`${label}: ${value}`); + } + } else { + out.push('X-Total-Count: 42'); + out.push('X-Request-Id: req_…'); + } + + if (out.length === 1) out.push('(sin headers CORS)'); + return out; +} + +function serverPanel(s) { + const server = s.server || {}; + const client = s.client || {}; + const blocks = []; + + if (s.target === 'same') { + return `# endpoint del mismo origen: deliberadamente sin headers CORS\n200 OK\nContent-Type: application/json`; + } + + const reason = preflightReason(client); + if (reason) { + blocks.push(`# preflight OPTIONS — ${esc(reason)}\n` + headerLines(server, 'preflight', client).join('\n')); + } + blocks.push(`# respuesta al ${client.method || 'GET'}\n` + headerLines(server, 'actual', client).join('\n')); + return blocks.join('\n\n'); +} + +function prettyQuery(server) { + return new URLSearchParams(server).toString().replace(/\+/g, ' ').replace(/%[0-9A-F]{2}/gi, (m) => { + try { return decodeURIComponent(m); } catch (e) { return m; } + }); +} + +function clientPanel(s) { + const c = s.client || {}; + const target = TARGETS[s.target || 'api']; + // Shown decoded: percent-encoding is unreadable on a projector, and this is + // exactly how the browser's own address bar and Network tab display it. + const query = prettyQuery(s.server || {}); + const url = target.base + (query ? '?' + query : ''); + const opts = []; + if (c.method && c.method !== 'GET') opts.push(` method: '${c.method}'`); + if (c.mode) opts.push(` mode: '${c.mode}'`); + if (c.credentials) opts.push(` credentials: '${c.credentials}'`); + if (c.headers) { + const hs = Object.entries(c.headers).map(([k, v]) => ` '${k}': '${v}'`).join(',\n'); + opts.push(` headers: {\n${hs}\n }`); + } + if (c.body) opts.push(` body: ${JSON.stringify(c.body)}`); + + let code = `await fetch('${esc(url)}'`; + if (opts.length) code += `, {\n${esc(opts.join(',\n'))}\n}`; + code += ')'; + + if (c.readHeaders) { + code += '\n\n' + c.readHeaders.map((h) => `res.headers.get('${h}')`).join('\n'); + } + return code; +} + +/* ------------------------------------------------------------------ * + * The live server log + * ------------------------------------------------------------------ */ + +const logEntries = []; +let lastSeq = 0; +const logBody = document.getElementById('logBody'); +const logStatus = document.getElementById('logStatus'); + +function connectLog() { + const es = new EventSource('/log/stream'); + es.onopen = () => { logStatus.textContent = 'en vivo'; logStatus.className = 'chip live'; }; + es.onerror = () => { logStatus.textContent = 'reconectando'; logStatus.className = 'chip dead'; }; + es.onmessage = (ev) => { + const entry = JSON.parse(ev.data); + logEntries.push(entry); + lastSeq = Math.max(lastSeq, entry.seq); + appendLogEntry(entry); + }; +} + +function appendLogEntry(e) { + if (logBody.firstElementChild && logBody.firstElementChild.className === 'hint') logBody.innerHTML = ''; + const div = document.createElement('div'); + div.className = 'entry ' + e.phase; + const time = new Date(e.at).toLocaleTimeString([], { hour12: false }); + const sent = (e.corsSent && e.corsSent.length) + ? `
↳ ${e.corsSent.map(esc).join('
↳ ')}
` + : `
↳ no mandó headers CORS
`; + const requested = e.requested && e.requested.method + ? `
pidió: ${esc(e.requested.method)}${e.requested.headers ? ' + ' + esc(e.requested.headers) : ''}
` + : ''; + div.innerHTML = + `
${e.method}${e.phase === 'preflight' ? 'preflight' : ''}` + + `${e.status}
` + + `
${esc(decodeURIComponent(e.url))}
` + + `
origen: ${esc(e.origin || '(ninguno)')} · cookies: ${e.cookies && e.cookies.length ? esc(e.cookies.join(', ')) : 'ninguna'} · ${time}
` + + requested + sent; + logBody.prepend(div); + while (logBody.children.length > 120) logBody.lastElementChild.remove(); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ------------------------------------------------------------------ * + * Running a scenario + * ------------------------------------------------------------------ */ + +async function runScenario(s, resultEl) { + const target = TARGETS[s.target || 'api']; + const params = new URLSearchParams(s.server || {}); + // A unique param keeps each run out of the browser's preflight cache, except + // where the scenario is specifically about that cache. + if (!s.stableUrl) params.set('_', Date.now().toString(36)); + const url = target.base + (params.toString() ? '?' + params.toString() : ''); + + const init = {}; + if (s.client.method) init.method = s.client.method; + if (s.client.mode) init.mode = s.client.mode; + if (s.client.credentials) init.credentials = s.client.credentials; + if (s.client.headers) init.headers = s.client.headers; + if (s.client.body) init.body = s.client.body; + + const seqBefore = lastSeq; + const t0 = performance.now(); + const out = { predicted: preflightReason(s.client) }; + + try { + const res = await fetch(url, init); + out.elapsed = Math.round(performance.now() - t0); + out.outcome = 'ok'; + out.status = res.status; + out.ok = res.ok; + out.type = res.type; + out.readable = {}; + for (const h of s.client.readHeaders || []) out.readable[h] = res.headers.get(h); + out.visibleHeaders = [...res.headers.keys()]; + try { out.body = await res.text(); } catch (e) { out.body = ''; } + } catch (err) { + out.elapsed = Math.round(performance.now() - t0); + out.outcome = 'blocked'; + out.error = `${err.name}: ${err.message}`; + } + + await sleep(280); // dejar que el log por SSE se ponga al día + out.trace = logEntries.filter((e) => e.seq > seqBefore); + renderResult(s, out, resultEl); + return out; +} + +function renderResult(s, out, el) { + const expectedOutcome = s.expect === 'fail' ? 'blocked' : 'ok'; + const matched = s.expect === 'any' || out.outcome === expectedOutcome; + const parts = []; + + parts.push( + `
` + + `${out.outcome === 'ok' ? 'La respuesta llegó a JavaScript' : 'Bloqueado por el navegador'}` + + (out.status !== undefined ? `HTTP ${out.status}` : '') + + (out.type ? `type: ${out.type}` : '') + + `${out.elapsed} ms` + + (matched ? '' : `no es lo que esperábamos`) + + `
` + ); + + if (out.error) parts.push(`
${esc(out.error)}\n\nEl navegador se niega a decir más que esto a propósito: una razón detallada ya filtraría información de otro origen. El mensaje real está en la consola de DevTools.
`); + + if (out.readable && Object.keys(out.readable).length) { + parts.push( + `
` + + Object.entries(out.readable).map(([k, v]) => + `
res.headers.get('${esc(k)}') → ${v === null ? 'null' : `${esc(v)}`}
` + ).join('') + + `
` + ); + } + + /* what the browser sent, per the server's own log */ + const trace = out.trace || []; + const sawPreflight = trace.some((e) => e.phase === 'preflight'); + let note = ''; + if (out.predicted && !sawPreflight) note = 'Se esperaba un preflight y no está: el navegador reutilizó uno cacheado.'; + if (!out.predicted && !sawPreflight && s.client.mode !== 'no-cors') note = 'Request simple: no hace falta preflight.'; + + parts.push( + `
` + + (trace.length + ? trace.map((e) => + `
` + + `${e.method}` + + `${e.phase === 'preflight' ? 'preflight' : 'request real'}${e.cookies && e.cookies.length ? ' · cookies: ' + esc(e.cookies.join(', ')) : ' · sin cookies'}` + + `${e.status}` + + `
`).join('') + : `
El servidor no registró nada en esta corrida.
`) + + (note ? `
${esc(note)}
` : '') + + `
` + ); + + if (out.body) { + parts.push( + `
Cuerpo de la respuesta (${out.body.length} bytes) y headers legibles` + + `
${esc(out.body.slice(0, 1400))}
` + + `
headers que JS puede ver: ${esc((out.visibleHeaders || []).join(', ') || '(ninguno)')}
` + + `
` + ); + } else if (out.outcome === 'ok') { + parts.push(`
Headers legibles
${esc((out.visibleHeaders || []).join(', ') || '(ninguno — respuesta opaca)')}
`); + } + + el.innerHTML = parts.join(''); + el.classList.add('shown'); + return matched; +} + +/* ------------------------------------------------------------------ * + * Special (non-fetch) demos + * ------------------------------------------------------------------ */ + +function renderSpecial(s, host) { + if (s.special === 'img') { + host.innerHTML = ` +
+
+ <img src="${esc(API)}/api/image">
+ sin headers CORS, y sin mandar header Origin + +
todavía no cargó
+
+
+ <img crossorigin="anonymous" …>
+ ahora SÍ es un request CORS, y falla + +
todavía no cargó
+
+
+
`; + host.querySelector('#imgGo').onclick = () => { + const stamp = Date.now(); + const plain = host.querySelector('#imgPlain'); + const cors = host.querySelector('#imgCors'); + plain.onload = () => (host.querySelector('#imgPlainState').innerHTML = 'cargó — el navegador nunca le pidió permiso a CORS'); + plain.onerror = () => (host.querySelector('#imgPlainState').innerHTML = 'falló'); + cors.onload = () => (host.querySelector('#imgCorsState').innerHTML = 'cargó'); + cors.onerror = () => (host.querySelector('#imgCorsState').innerHTML = 'bloqueada — crossorigin metió a esta imagen dentro de CORS, y el servidor no mandó Allow-Origin'); + plain.src = `${API}/api/image?t=${stamp}`; + cors.src = `${API}/api/image?t=${stamp}&nocors=1`; + }; + host.classList.add('shown'); + return; + } + + if (s.special === 'form') { + host.innerHTML = ` +
+
+ + + + este endpoint no tiene configurado ni un header CORS +
+
+ +
+ La respuesta del servidor se renderiza en ese frame, así que podés ver que ocurrió — pero el JavaScript de esta + página no puede leer ni un byte. La escritura pasó; lo único protegido es la lectura. +
`; + host.classList.add('shown'); + return; + } +} + +/* ------------------------------------------------------------------ * + * Building the page + * ------------------------------------------------------------------ */ + +const EXPECT_LABEL = { + pass: 'debería funcionar', + fail: 'debería bloquearse', + partial: 'funciona, pero…', + special: 'no es un fetch', +}; + +function buildCard(s, number) { + const card = document.createElement('article'); + card.className = 'card'; + card.dataset.expect = s.expect; + card.id = 'sc-' + s.id; + + const reason = s.client ? preflightReason(s.client) : null; + const chips = [`${EXPECT_LABEL[s.expect]}`]; + if (s.client) { + chips.push(reason + ? `con preflight` + : `request simple`); + } + if (s.target === 'same') chips.push('mismo origen'); + if (s.target === 'cross-site') chips.push('otro sitio'); + if (s.client && s.client.credentials === 'include') chips.push('credenciales'); + if (s.needsCookie) chips.push('necesita cookies'); + + card.innerHTML = + `
${number}

${s.title}

${chips.join('')}
` + + `

${s.why}

` + + (s.client + ? `
+

El servidor manda

${serverPanel(s)}
+

La página ejecuta

${clientPanel(s)}
+
+
+ + ${s.watch ? `Observá: ${s.watch}` : ''} +
` + : `${s.watch ? `
Observá: ${s.watch}
` : ''}`) + + `
`; + + const result = card.querySelector('.result'); + if (s.client) { + const btn = card.querySelector('button.run'); + btn.onclick = async () => { + btn.disabled = true; + btn.textContent = 'probando…'; + await runScenario(s, result); + btn.disabled = false; + btn.textContent = 'Probar de nuevo'; + }; + card._run = () => runScenario(s, result); + } else { + renderSpecial(s, result); + } + return card; +} + +const scenarioHost = document.getElementById('scenarios'); +const nav = document.getElementById('nav'); +const cards = []; + +window.GROUPS.forEach((group, gi) => { + const section = document.createElement('section'); + section.className = 'group'; + section.id = 'g-' + group.id; + section.innerHTML = `

${group.title}

${group.blurb}

`; + const list = window.SCENARIOS.filter((s) => s.group === group.id); + list.forEach((s, i) => { + const card = buildCard(s, `${gi + 1}.${i + 1}`); + section.appendChild(card); + if (card._run) cards.push({ s, card }); + }); + scenarioHost.appendChild(section); + nav.insertAdjacentHTML('beforeend', `${group.title}`); +}); +nav.insertAdjacentHTML('beforeend', `6 · Laboratorio`); + +/* ------------------------------------------------------------------ * + * Header: origin chips, cookies, run-all, theme, font size + * ------------------------------------------------------------------ */ + +document.getElementById('pageOrigin').textContent = PAGE; +document.getElementById('apiOrigin').textContent = API; +document.getElementById('origins').innerHTML = + `página ${PAGE}` + + `API ${API}` + + `otro sitio ${CROSS_SITE}` + + `cookies ?`; + +async function refreshCookieChip() { + const chip = document.getElementById('cookieChip'); + try { + const res = await fetch(`${API}/api/data?acao=echo&acac=true&_=${Date.now()}`, { credentials: 'include' }); + const data = await res.json(); + const n = data.seenByServer.cookies.length; + chip.innerHTML = `cookies ${n ? n + ' en la API' : 'ninguna'}`; + chip.className = 'chip ' + (n ? 'live' : 'dead'); + } catch (e) { + chip.innerHTML = 'API no responde'; + chip.className = 'chip dead'; + } +} + +document.getElementById('cookieSet').onclick = () => { + window.open(`${API}/api/cookie/set`, '_blank'); + setTimeout(refreshCookieChip, 1500); +}; + +document.getElementById('runAll').onclick = async (ev) => { + const btn = ev.currentTarget; + btn.disabled = true; + let surprises = 0; + for (let i = 0; i < cards.length; i++) { + const { s, card } = cards[i]; + btn.textContent = `probando ${i + 1}/${cards.length}…`; + card.scrollIntoView({ block: 'center', behavior: 'smooth' }); + const out = await card._run(); + const expected = s.expect === 'fail' ? 'blocked' : 'ok'; + if (out.outcome !== expected) surprises++; + } + btn.disabled = false; + btn.textContent = 'Probar todo'; + alert( + `Se probaron ${cards.length} escenarios.\n` + + `${cards.length - surprises} se comportaron exactamente como dice la ficha.\n` + + (surprises + ? `${surprises} no — buscá la etiqueta naranja "no es lo que esperábamos". ` + + `La causa habitual es que todavía no pusiste las cookies, o que el navegador bloquea cookies de terceros.` + : `Ninguna sorpresa. Tu navegador está jugando según el manual.`) + ); +}; + +document.getElementById('clearAll').onclick = () => { + document.querySelectorAll('.result').forEach((r) => { + if (r.querySelector('form') || r.querySelector('img')) return; + r.classList.remove('shown'); + r.innerHTML = ''; + }); + document.querySelectorAll('button.run').forEach((b) => { if (b.textContent === 'Probar de nuevo') b.textContent = 'Probar'; }); +}; + +document.getElementById('logClear').onclick = () => { + logEntries.length = 0; + logBody.innerHTML = '
Limpio.
'; +}; + +const root = document.documentElement; +document.getElementById('theme').onclick = () => { + const current = root.getAttribute('data-theme'); + root.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark'); +}; +let scale = 1; +const setScale = (v) => { scale = Math.min(1.6, Math.max(0.8, v)); root.style.setProperty('--scale', scale); }; +document.getElementById('fontUp').onclick = () => setScale(scale + 0.1); +document.getElementById('fontDown').onclick = () => setScale(scale - 0.1); + +connectLog(); +refreshCookieChip(); + +/* ------------------------------------------------------------------ * + * The free playground: every knob, wired to the same runner. + * The two preview panels update as you type, so the class can see what + * a toggle changes before anything is sent. + * ------------------------------------------------------------------ */ + +const pg = document.getElementById('pg'); + +pg.innerHTML = ` +

Armá cualquier configuración de CORS

+

Cambiá lo que quieras: los paneles de abajo se actualizan en vivo. Después probalo y mirá el log del servidor.

+
+
+ Request que hace la página +
+
+
+
+
+
+
+
+ +
+ Headers de la respuesta real +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ Headers del preflight +
+
+
+
+
+
+
+
+
+
+
+ +
+

El servidor va a mandar

+

La página va a ejecutar

+
+
+ + +
+
`; + +const P = (id) => document.getElementById('p_' + id); + +function parseHeaderLines(text) { + const out = {}; + for (const line of text.split('\n')) { + const i = line.indexOf(':'); + if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim(); + } + return Object.keys(out).length ? out : undefined; +} + +function playgroundScenario() { + const server = {}; + const put = (key, value) => { if (value !== '' && value !== undefined && value !== null) server[key] = String(value); }; + + put('acao', P('acao').value.trim()); + if (P('acac').checked) server.acac = 'true'; + put('aceh', P('aceh').value.trim()); + if (P('vary').checked) server.vary = '1'; + if (P('status').value && P('status').value !== '200') server.status = P('status').value; + if (P('errnocors').checked) server.errnocors = '1'; + if (Number(P('delay').value) > 0) server.delay = P('delay').value; + + put('acam', P('acam').value.trim()); + put('acah', P('acah').value.trim()); + put('acma', P('acma').value.trim()); + if (P('pfstatus').value && P('pfstatus').value !== '204') server.pfstatus = P('pfstatus').value; + if (P('pfcors').checked) server.pfcors = '0'; + if (P('pfacao').checked) server.pfacao = '0'; + if (P('pfacac').checked) server.pfacac = '0'; + if (P('pfredirect').checked) server.pfredirect = '1'; + + const client = { method: P('method').value }; + if (P('mode').value) client.mode = P('mode').value; + if (P('credentials').value) client.credentials = P('credentials').value; + const headers = parseHeaderLines(P('headers').value); + if (headers) client.headers = headers; + const body = P('body').value.trim(); + if (body && !['GET', 'HEAD'].includes(client.method)) client.body = body; + const read = P('read').value.split(',').map((h) => h.trim()).filter(Boolean); + if (read.length) client.readHeaders = read; + + return { + id: 'playground', + target: P('target').value, + stableUrl: P('stable').checked, + expect: 'any', + server, + client, + }; +} + +function refreshPlayground() { + const s = playgroundScenario(); + document.getElementById('pgServer').innerHTML = serverPanel(s); + document.getElementById('pgClient').innerHTML = clientPanel(s); + const reason = preflightReason(s.client); + document.getElementById('pgVerdict').innerHTML = reason + ? `Este request va a tener preflight — ${esc(reason)}.` + : s.client.mode === 'no-cors' + ? `Modo no-cors: sin preflight, y los headers fuera de la safelist se descartan.` + : `Esto es un request simple — sin preflight.`; +} + +pg.addEventListener('input', refreshPlayground); +pg.addEventListener('change', refreshPlayground); +refreshPlayground(); + +document.getElementById('pgRun').onclick = async (ev) => { + const btn = ev.currentTarget; + btn.disabled = true; + btn.textContent = 'probando…'; + await runScenario(playgroundScenario(), document.getElementById('pgResult')); + btn.disabled = false; + btn.textContent = 'Probar'; +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..18f7727 --- /dev/null +++ b/public/index.html @@ -0,0 +1,292 @@ + + + + + +Laboratorio de CORS + + + + +
+
+
Laboratorio de CORS todas las configuraciones, una sola página
+
+
+ + + + + + +
+
+ +
+ +
+
+
+

Qué es esta página

+

+ Esta página se sirve desde . Hace requests a una API en + — un origen distinto, así que todos ellos pasan por CORS. + La API reconstruye sus headers CORS a partir de los query params en cada request, de modo que cada escenario + de abajo reconfigura el servidor al vuelo. No hace falta reiniciar nada. +

+

+ El panel de la derecha es el log del propio servidor, en vivo. Es lo más útil de todo esto: + muestra qué recibió y qué respondió el servidor realmente, al lado de lo que el navegador estuvo dispuesto a + contarle a JavaScript. Esas dos cosas difieren mucho más seguido de lo que uno esperaría. +

+
    +
  1. Abrí DevTools → Network y dejalo a la vista. Todas las respuestas bloqueadas están ahí.
  2. +
  3. Hacé clic una vez en Poner cookies en la API, así los escenarios de credenciales tienen algo para mandar.
  4. +
  5. Recorré los grupos de arriba abajo, o tocá Probar todo y comentá el resumen.
  6. +
+
+
+ +
+

6 · Laboratorio libre

+

Todas las perillas, para improvisar cuando alguien pregunta "¿y si…?".

+
+
+ +

+ El código del servidor está en server.js: unas 330 líneas de Node puro, sin dependencias, escritas para + leerse en voz alta. La lista de escenarios son datos en public/scenarios.js, así que podés agregar los tuyos. +

+
+ + +
+ + + + + diff --git a/public/scenarios.js b/public/scenarios.js new file mode 100644 index 0000000..77719d4 --- /dev/null +++ b/public/scenarios.js @@ -0,0 +1,506 @@ +/* + * 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.', + }, +]; diff --git a/server.js b/server.js new file mode 100644 index 0000000..cf07386 --- /dev/null +++ b/server.js @@ -0,0 +1,407 @@ +/* + * CORS classroom server. + * + * Starts TWO listeners from one process: + * + * http://localhost:3000 -> "the app". Serves the test page (public/). + * Also exposes /same-origin/api so you can show + * that a same-origin request needs no CORS at all, + * and /log/stream (SSE) which mirrors the server log + * into the page. Both are same-origin for the page, + * so neither is affected by CORS. + * + * http://localhost:3001 -> "the API". Cross-ORIGIN from the page. + * http://127.0.0.1:3001 Same listener, but a different SITE, which matters + * for cookies (SameSite) and not for CORS. + * + * Every CORS response header is driven by query parameters, so the page can + * reconfigure the server per request instead of you restarting anything: + * + * acao=* Access-Control-Allow-Origin: * + * acao=echo reflect the request's Origin header back + * acao= any literal value (try a list or a trailing slash) + * (omit acao) send no Access-Control-Allow-Origin at all + * + * acac=true Access-Control-Allow-Credentials: true + * acam=PUT,DELETE Access-Control-Allow-Methods + * acah=echo reflect Access-Control-Request-Headers + * acah= Access-Control-Allow-Headers + * aceh=X-Total-Count Access-Control-Expose-Headers + * acma=60 Access-Control-Max-Age (preflight cache, seconds) + * vary=1 add Vary: Origin + * + * pfstatus=401 status code for the OPTIONS preflight response + * pfcors=0 answer the preflight WITHOUT any CORS headers + * pfacao=0 omit Allow-Origin on the preflight ONLY + * pfacac=0 omit Allow-Credentials on the preflight ONLY + * pfredirect=1 make the preflight respond with a 302 + * + * status=500 status code for the actual response + * errnocors=1 drop CORS headers when status >= 400 + * (the classic "error handler runs before CORS" bug) + * delay=300 artificial latency in ms + * + * Nothing here is production code: it is deliberately explicit so it can be + * read out loud in class. + */ + +'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { URL } = require('url'); + +const PAGE_PORT = Number(process.env.PAGE_PORT || 3000); +const API_PORT = Number(process.env.API_PORT || 3001); + +/* ------------------------------------------------------------------ * + * Shared request log. The page reads it over SSE from the page origin, + * so watching the log never interferes with the CORS demo itself. + * ------------------------------------------------------------------ */ + +const logClients = new Set(); +let seq = 0; + +function logEvent(entry) { + const record = { seq: ++seq, at: Date.now(), ...entry }; + const frame = `data: ${JSON.stringify(record)}\n\n`; + for (const res of logClients) res.write(frame); + const tag = record.phase === 'preflight' ? 'PREFLIGHT' : 'request '; + console.log( + `[${tag}] ${record.method} ${record.url} origin=${record.origin || '(none)'} -> ${record.status}` + ); + return record; +} + +/* ------------------------------------------------------------------ * + * CORS header construction, from query params. + * ------------------------------------------------------------------ */ + +function applyCorsHeaders(req, params, headers) { + const origin = req.headers.origin; + const sent = []; + + const acao = params.get('acao'); + if (acao !== null) { + const value = acao === 'echo' ? origin || 'null' : acao; + headers['Access-Control-Allow-Origin'] = value; + sent.push(`Access-Control-Allow-Origin: ${value}`); + } + + const acac = params.get('acac'); + if (acac !== null) { + headers['Access-Control-Allow-Credentials'] = acac; + sent.push(`Access-Control-Allow-Credentials: ${acac}`); + } + + const aceh = params.get('aceh'); + if (aceh !== null) { + headers['Access-Control-Expose-Headers'] = aceh; + sent.push(`Access-Control-Expose-Headers: ${aceh}`); + } + + if (params.get('vary') === '1') { + headers['Vary'] = 'Origin'; + sent.push('Vary: Origin'); + } + + return sent; +} + +function applyPreflightHeaders(req, params, headers) { + const sent = []; + + const acam = params.get('acam'); + if (acam !== null) { + const value = acam === 'echo' ? req.headers['access-control-request-method'] || '' : acam; + headers['Access-Control-Allow-Methods'] = value; + sent.push(`Access-Control-Allow-Methods: ${value}`); + } + + const acah = params.get('acah'); + if (acah !== null) { + const value = acah === 'echo' ? req.headers['access-control-request-headers'] || '' : acah; + headers['Access-Control-Allow-Headers'] = value; + sent.push(`Access-Control-Allow-Headers: ${value}`); + } + + const acma = params.get('acma'); + if (acma !== null) { + headers['Access-Control-Max-Age'] = acma; + sent.push(`Access-Control-Max-Age: ${acma}`); + } + + return sent; +} + +function parseCookies(req) { + const raw = req.headers.cookie; + if (!raw) return {}; + return raw.split(';').reduce((acc, pair) => { + const i = pair.indexOf('='); + if (i > 0) acc[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); + return acc; + }, {}); +} + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ------------------------------------------------------------------ * + * The API origin (:3001) — cross-origin from the page. + * ------------------------------------------------------------------ */ + +const api = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const params = url.searchParams; + const cookies = parseCookies(req); + + const delay = Number(params.get('delay') || 0); + if (delay > 0) await sleep(Math.min(delay, 5000)); + + /* --- cookie helpers: open these directly in a tab (first-party) --- */ + + if (url.pathname === '/api/cookie/set') { + // Both listeners live on the "localhost" site, so a Lax cookie set here is + // still sent from the page on :3000 (different ORIGIN, same SITE). + // 127.0.0.1 is a different site, which is what makes the SameSite demo work. + const headers = { + 'Content-Type': 'text/html; charset=utf-8', + 'Set-Cookie': [ + 'cors_demo_lax=lax-value; Path=/; SameSite=Lax', + 'cors_demo_strict=strict-value; Path=/; SameSite=Strict', + ], + }; + applyCorsHeaders(req, params, headers); + res.writeHead(200, headers); + res.end( + `Cookies set + +

Cookies set on ${url.host}

+

Set: cors_demo_lax (SameSite=Lax) and + cors_demo_strict (SameSite=Strict).

+

Go back to the test page and run the credentials scenarios.

+

Clear them

` + ); + logEvent({ phase: 'actual', method: req.method, url: url.pathname, origin: req.headers.origin, status: 200, corsSent: [], cookies: Object.keys(cookies) }); + return; + } + + if (url.pathname === '/api/cookie/clear') { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Set-Cookie': [ + 'cors_demo_lax=; Path=/; Max-Age=0', + 'cors_demo_strict=; Path=/; Max-Age=0', + ], + }); + res.end('Cookies cleared. Set again'); + return; + } + + /* --- a real image, to show that is not subject to CORS --- */ + + if (url.pathname === '/api/image') { + const headers = { 'Content-Type': 'image/svg+xml' }; + applyCorsHeaders(req, params, headers); + res.writeHead(200, headers); + res.end( + ` + + loaded from :3001 + ` + ); + logEvent({ phase: 'actual', method: 'GET', url: url.pathname, origin: req.headers.origin || '(no Origin: img tag)', status: 200, corsSent: [], cookies: Object.keys(cookies) }); + return; + } + + /* --- a classic script tag target (JSONP-style), also CORS-free --- */ + + if (url.pathname === '/api/script') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(`window.__scriptTagRan = ${Date.now()};`); + logEvent({ phase: 'actual', method: 'GET', url: url.pathname, origin: req.headers.origin || '(no Origin: script tag)', status: 200, corsSent: [], cookies: Object.keys(cookies) }); + return; + } + + /* --- the preflight --- */ + + if (req.method === 'OPTIONS') { + const headers = {}; + const sent = []; + + if (params.get('pfredirect') === '1') { + headers['Location'] = url.pathname + '?acao=*&acam=echo&acah=echo'; + res.writeHead(302, headers); + res.end(); + logEvent({ phase: 'preflight', method: 'OPTIONS', url: url.pathname + url.search, origin: req.headers.origin, status: 302, corsSent: ['(302 redirect — never allowed on a preflight)'], cookies: Object.keys(cookies) }); + return; + } + + if (params.get('pfcors') !== '0') { + // A preflight is a response of its own: it needs Allow-Origin, and in + // credentialed mode Allow-Credentials, whether or not the actual + // response carries them. pfacao/pfacac let you drop one to show that. + const preflightParams = new URLSearchParams(params); + if (params.get('pfacao') === '0') preflightParams.delete('acao'); + if (params.get('pfacac') === '0') preflightParams.delete('acac'); + sent.push(...applyCorsHeaders(req, preflightParams, headers)); + sent.push(...applyPreflightHeaders(req, preflightParams, headers)); + } + + const status = Number(params.get('pfstatus') || 204); + headers['Content-Length'] = '0'; + res.writeHead(status, headers); + res.end(); + + logEvent({ + phase: 'preflight', + method: 'OPTIONS', + url: url.pathname + url.search, + origin: req.headers.origin, + status, + requested: { + method: req.headers['access-control-request-method'] || null, + headers: req.headers['access-control-request-headers'] || null, + }, + corsSent: sent, + cookies: Object.keys(cookies), + }); + return; + } + + /* --- the actual request --- */ + + const body = await readBody(req); + const status = Number(params.get('status') || 200); + const headers = { 'Content-Type': 'application/json; charset=utf-8' }; + let sent = []; + + const suppress = params.get('errnocors') === '1' && status >= 400; + if (!suppress) sent = applyCorsHeaders(req, params, headers); + + // Custom response headers that exist purely so the exposure demos have + // something to try to read. + headers['Cache-Control'] = 'no-store'; // safelisted: JS can read this one without Expose-Headers + headers['X-Total-Count'] = '42'; + headers['X-Request-Id'] = 'req_' + Math.random().toString(36).slice(2, 10); + headers['X-Powered-By'] = 'cors-class'; + + const payload = { + ok: status < 400, + note: suppress ? 'CORS headers were intentionally omitted on this error response' : undefined, + seenByServer: { + origin: req.headers.origin || null, + method: req.method, + path: url.pathname, + contentType: req.headers['content-type'] || null, + authorization: req.headers.authorization ? '(present)' : null, + customHeaders: Object.keys(req.headers).filter((h) => h.startsWith('x-')), + cookies: Object.keys(cookies), + body: body || null, + }, + }; + + res.writeHead(status, headers); + res.end(JSON.stringify(payload, null, 2)); + + logEvent({ + phase: 'actual', + method: req.method, + url: url.pathname + url.search, + origin: req.headers.origin, + status, + corsSent: sent, + cookies: Object.keys(cookies), + }); +}); + +/* ------------------------------------------------------------------ * + * The page origin (:3000) — static files + same-origin API + log SSE. + * ------------------------------------------------------------------ */ + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +const page = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + // Deliberately NO CORS headers here: it is same-origin for the page, + // which is exactly the point of the first scenario. + if (url.pathname === '/same-origin/api') { + const body = await readBody(req); + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Total-Count': '42', + 'X-Request-Id': 'same_origin', + }); + res.end( + JSON.stringify( + { + ok: true, + seenByServer: { + origin: req.headers.origin || null, + method: req.method, + cookies: Object.keys(parseCookies(req)), + body: body || null, + }, + }, + null, + 2 + ) + ); + return; + } + + if (url.pathname === '/log/stream') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write('retry: 1000\n\n'); + logClients.add(res); + req.on('close', () => logClients.delete(res)); + return; + } + + const rel = url.pathname === '/' ? '/index.html' : url.pathname; + const file = path.join(__dirname, 'public', path.normalize(rel).replace(/^(\.\.[/\\])+/, '')); + + fs.readFile(file, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + res.end(data); + }); +}); + +page.listen(PAGE_PORT, () => { + api.listen(API_PORT, () => { + console.log(''); + console.log(' CORS classroom'); + console.log(' ─────────────────────────────────────────────'); + console.log(` page (origin A) http://localhost:${PAGE_PORT} <- open this`); + console.log(` API (origin B) http://localhost:${API_PORT}`); + console.log(` API (site C) http://127.0.0.1:${API_PORT}`); + console.log(''); + console.log(' Every request the API sees is printed below.'); + console.log(''); + }); +});