This commit is contained in:
Matias 2026-10-01 13:02:44 -03:00
commit 4b4b458584
7 changed files with 2025 additions and 0 deletions

81
README.md Normal file
View file

@ -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.

9
package.json Normal file
View file

@ -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"
}
}

33
public/_selftest.html Normal file
View file

@ -0,0 +1,33 @@
<!doctype html><meta charset="utf-8"><title>selftest</title><body><pre id="out">running</pre>
<script src="scenarios.js"></script>
<script>
const PAGE = location.origin, HOST = location.hostname;
const OTHER = HOST === 'localhost' ? '127.0.0.1' : 'localhost';
const T = { api: PAGE.replace(':3000', ':3001') + '/api/data',
same: PAGE + '/same-origin/api',
'cross-site': `http://${OTHER}:3001/api/data` };
(async () => {
const lines = [];
let bad = 0;
for (const s of window.SCENARIOS) {
if (!s.client) continue;
const p = new URLSearchParams(s.server || {});
if (!s.stableUrl) p.set('_', Math.random().toString(36).slice(2));
const init = {};
for (const k of ['method', 'mode', 'credentials', 'headers', 'body']) if (s.client[k]) init[k] = s.client[k];
let got, detail = '';
try {
const r = await fetch(T[s.target || 'api'] + '?' + p, init);
got = 'ok'; detail = `status=${r.status} type=${r.type}`;
if (s.client.readHeaders) detail += ' read=' + s.client.readHeaders.map(h => h + ':' + r.headers.get(h)).join('|');
} catch (e) { got = 'blocked'; detail = e.name; }
const want = s.expect === 'fail' ? 'blocked' : 'ok';
const okmark = got === want ? 'PASS' : 'MISMATCH';
if (got !== want) bad++;
lines.push(`${okmark.padEnd(9)} ${s.id.padEnd(34)} want=${want.padEnd(8)} got=${got.padEnd(8)} ${detail}`);
}
lines.push('', `TOTAL ${lines.length} scenarios, ${bad} mismatches`);
document.getElementById('out').textContent = lines.join('\n');
document.title = bad ? 'MISMATCHES:' + bad : 'ALL PASS';
})();
</script>

697
public/app.js Normal file
View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function headerLines(server, phase, client) {
const out = [];
const isPreflight = phase === 'preflight';
if (isPreflight && server.pfredirect === '1') {
return ['<span class="hl">302 Found</span>', '<span class="k">Location</span>: /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 ? '<span class="hl">' : ''}${status} ${statusText}${bad ? '</span>' : ''}`);
const suppressed =
(isPreflight && server.pfcors === '0') ||
(!isPreflight && server.errnocors === '1' && Number(status) >= 400);
if (suppressed) {
out.push('<span class="c">(ningún header CORS)</span>');
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' ? `<span class="c">${PAGE} (reflejado)</span>` : value;
out.push(`<span class="k">${label}</span>: ${value}`);
}
if (server.vary === '1') out.push('<span class="k">Vary</span>: 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 = `<span class="c">${echoed || '(no pidió nada)'} (reflejado)</span>`;
}
out.push(`<span class="k">${label}</span>: ${value}`);
}
} else {
out.push('<span class="c">X-Total-Count: 42</span>');
out.push('<span class="c">X-Request-Id: req_…</span>');
}
if (out.length === 1) out.push('<span class="c">(sin headers CORS)</span>');
return out;
}
function serverPanel(s) {
const server = s.server || {};
const client = s.client || {};
const blocks = [];
if (s.target === 'same') {
return `<span class="c"># endpoint del mismo origen: deliberadamente sin headers CORS</span>\n200 OK\n<span class="c">Content-Type: application/json</span>`;
}
const reason = preflightReason(client);
if (reason) {
blocks.push(`<span class="c"># preflight OPTIONS — ${esc(reason)}</span>\n` + headerLines(server, 'preflight', client).join('\n'));
}
blocks.push(`<span class="c"># respuesta al ${client.method || 'GET'}</span>\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 = `<span class="k">await fetch</span>(<span class="s">'${esc(url)}'</span>`;
if (opts.length) code += `, {\n${esc(opts.join(',\n'))}\n}`;
code += ')';
if (c.readHeaders) {
code += '\n\n' + c.readHeaders.map((h) => `res.headers.<span class="k">get</span>(<span class="s">'${h}'</span>)`).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)
? `<div class="sub sent">↳ ${e.corsSent.map(esc).join('<br>↳ ')}</div>`
: `<div class="sub none">↳ no mandó headers CORS</div>`;
const requested = e.requested && e.requested.method
? `<div class="sub">pidió: ${esc(e.requested.method)}${e.requested.headers ? ' + ' + esc(e.requested.headers) : ''}</div>`
: '';
div.innerHTML =
`<div class="line1"><span class="m">${e.method}</span><span class="c">${e.phase === 'preflight' ? 'preflight' : ''}</span>` +
`<span class="s s${String(e.status)[0]}">${e.status}</span></div>` +
`<div class="sub">${esc(decodeURIComponent(e.url))}</div>` +
`<div class="sub">origen: ${esc(e.origin || '(ninguno)')} · cookies: ${e.cookies && e.cookies.length ? esc(e.cookies.join(', ')) : 'ninguna'} · ${time}</div>` +
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(
`<div class="verdict">` +
`<span class="big ${out.outcome}">${out.outcome === 'ok' ? 'La respuesta llegó a JavaScript' : 'Bloqueado por el navegador'}</span>` +
(out.status !== undefined ? `<span class="badge neutral">HTTP ${out.status}</span>` : '') +
(out.type ? `<span class="badge neutral">type: ${out.type}</span>` : '') +
`<span class="meta">${out.elapsed} ms</span>` +
(matched ? '' : `<span class="surprise">no es lo que esperábamos</span>`) +
`</div>`
);
if (out.error) parts.push(`<div class="errbox">${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.</div>`);
if (out.readable && Object.keys(out.readable).length) {
parts.push(
`<div class="kv">` +
Object.entries(out.readable).map(([k, v]) =>
`<div>res.headers.get('${esc(k)}') → ${v === null ? '<span class="null">null</span>' : `<span class="val">${esc(v)}</span>`}</div>`
).join('') +
`</div>`
);
}
/* 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(
`<div class="trace">` +
(trace.length
? trace.map((e) =>
`<div class="trace-row ${e.phase === 'preflight' ? 'pre' : ''}">` +
`<span class="m">${e.method}</span>` +
`<span>${e.phase === 'preflight' ? 'preflight' : 'request real'}${e.cookies && e.cookies.length ? ' · cookies: ' + esc(e.cookies.join(', ')) : ' · sin cookies'}</span>` +
`<span class="st">${e.status}</span>` +
`</div>`).join('')
: `<div class="trace-empty">El servidor no registró nada en esta corrida.</div>`) +
(note ? `<div class="trace-empty">${esc(note)}</div>` : '') +
`</div>`
);
if (out.body) {
parts.push(
`<details class="more"><summary>Cuerpo de la respuesta (${out.body.length} bytes) y headers legibles</summary>` +
`<pre>${esc(out.body.slice(0, 1400))}</pre>` +
`<pre>headers que JS puede ver: ${esc((out.visibleHeaders || []).join(', ') || '(ninguno)')}</pre>` +
`</details>`
);
} else if (out.outcome === 'ok') {
parts.push(`<details class="more"><summary>Headers legibles</summary><pre>${esc((out.visibleHeaders || []).join(', ') || '(ninguno — respuesta opaca)')}</pre></details>`);
}
el.innerHTML = parts.join('');
el.classList.add('shown');
return matched;
}
/* ------------------------------------------------------------------ *
* Special (non-fetch) demos
* ------------------------------------------------------------------ */
function renderSpecial(s, host) {
if (s.special === 'img') {
host.innerHTML = `
<div class="demo-row">
<div class="demo-box">
<b>&lt;img src="${esc(API)}/api/image"&gt;</b><br>
sin headers CORS, y sin mandar header Origin
<img id="imgPlain" alt="">
<div id="imgPlainState">todavía no cargó</div>
</div>
<div class="demo-box">
<b>&lt;img crossorigin="anonymous" …&gt;</b><br>
ahora SÍ es un request CORS, y falla
<img id="imgCors" crossorigin="anonymous" alt="">
<div id="imgCorsState">todavía no cargó</div>
</div>
</div>
<div class="actions"><button class="run" id="imgGo">Cargar las dos imágenes</button></div>`;
host.querySelector('#imgGo').onclick = () => {
const stamp = Date.now();
const plain = host.querySelector('#imgPlain');
const cors = host.querySelector('#imgCors');
plain.onload = () => (host.querySelector('#imgPlainState').innerHTML = '<span style="color:var(--ok)">cargó — el navegador nunca le pidió permiso a CORS</span>');
plain.onerror = () => (host.querySelector('#imgPlainState').innerHTML = '<span style="color:var(--bad)">falló</span>');
cors.onload = () => (host.querySelector('#imgCorsState').innerHTML = '<span style="color:var(--ok)">cargó</span>');
cors.onerror = () => (host.querySelector('#imgCorsState').innerHTML = '<span style="color:var(--bad)">bloqueada — crossorigin metió a esta imagen dentro de CORS, y el servidor no mandó Allow-Origin</span>');
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 = `
<form method="POST" action="${esc(API)}/api/data" target="cors-sink" id="csrfForm">
<div class="actions">
<input type="text" name="amount" value="1000" style="width:7rem">
<input type="text" name="to" value="attacker" style="width:9rem">
<button class="run" type="submit">Enviar formulario a otro origen</button>
<span class="watch">este endpoint no tiene configurado ni un header CORS</span>
</div>
</form>
<iframe name="cors-sink" class="sink"></iframe>
<div class="watch" style="margin-top:.4rem">
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 <em>escritura</em> pasó; lo único protegido es la <em>lectura</em>.
</div>`;
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 = [`<span class="badge ${s.expect}">${EXPECT_LABEL[s.expect]}</span>`];
if (s.client) {
chips.push(reason
? `<span class="badge partial" title="${esc(reason)}">con preflight</span>`
: `<span class="badge neutral">request simple</span>`);
}
if (s.target === 'same') chips.push('<span class="badge neutral">mismo origen</span>');
if (s.target === 'cross-site') chips.push('<span class="badge neutral">otro sitio</span>');
if (s.client && s.client.credentials === 'include') chips.push('<span class="badge neutral">credenciales</span>');
if (s.needsCookie) chips.push('<span class="badge neutral" title="Primero tocá &quot;Poner cookies en la API&quot;">necesita cookies</span>');
card.innerHTML =
`<div class="card-head"><span class="num">${number}</span><h3>${s.title}</h3>${chips.join('')}</div>` +
`<p class="why">${s.why}</p>` +
(s.client
? `<div class="cols">
<div class="panel"><h4>El servidor manda</h4><pre>${serverPanel(s)}</pre></div>
<div class="panel"><h4>La página ejecuta</h4><pre>${clientPanel(s)}</pre></div>
</div>
<div class="actions">
<button class="run">Probar</button>
${s.watch ? `<span class="watch"><b>Observá:</b> ${s.watch}</span>` : ''}
</div>`
: `${s.watch ? `<div class="watch"><b>Observá:</b> ${s.watch}</div>` : ''}`) +
`<div class="result"></div>`;
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 = `<h2>${group.title}</h2><p class="blurb">${group.blurb}</p>`;
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', `<a href="#g-${group.id}">${group.title}</a>`);
});
nav.insertAdjacentHTML('beforeend', `<a href="#g-playground">6 · Laboratorio</a>`);
/* ------------------------------------------------------------------ *
* Header: origin chips, cookies, run-all, theme, font size
* ------------------------------------------------------------------ */
document.getElementById('pageOrigin').textContent = PAGE;
document.getElementById('apiOrigin').textContent = API;
document.getElementById('origins').innerHTML =
`<span class="chip"><b>página</b> ${PAGE}</span>` +
`<span class="chip"><b>API</b> ${API}</span>` +
`<span class="chip"><b>otro sitio</b> ${CROSS_SITE}</span>` +
`<span class="chip" id="cookieChip"><b>cookies</b> ?</span>`;
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 = `<b>cookies</b> ${n ? n + ' en la API' : 'ninguna'}`;
chip.className = 'chip ' + (n ? 'live' : 'dead');
} catch (e) {
chip.innerHTML = '<b>API</b> 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 = '<div class="hint">Limpio.</div>';
};
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 = `
<h2>Armá cualquier configuración de CORS</h2>
<p>Cambiá lo que quieras: los paneles de abajo se actualizan en vivo. Después probalo y mirá el log del servidor.</p>
<div class="pg-grid">
<fieldset>
<legend>Request que hace la página</legend>
<div class="field"><label>destino</label><select id="p_target">
<option value="api">otro origen (:3001)</option>
<option value="cross-site">otro sitio (${OTHER_HOST}:3001)</option>
<option value="same">mismo origen</option>
</select></div>
<div class="field"><label>método</label><select id="p_method">
<option>GET</option><option>HEAD</option><option>POST</option>
<option>PUT</option><option>PATCH</option><option>DELETE</option>
</select></div>
<div class="field"><label>mode</label><select id="p_mode">
<option value="">cors (por defecto)</option><option value="no-cors">no-cors</option><option value="same-origin">same-origin</option>
</select></div>
<div class="field"><label>credentials</label><select id="p_credentials">
<option value="">same-origin (por defecto)</option><option value="omit">omit</option><option value="include">include</option>
</select></div>
<div class="field"><label>headers</label><textarea id="p_headers" placeholder="Content-Type: application/json&#10;X-Api-Key: abc123"></textarea></div>
<div class="field"><label>body</label><textarea id="p_body" placeholder='{"hola":"mundo"}'></textarea></div>
<div class="field"><label>headers a leer</label><input type="text" id="p_read" value="Content-Type, X-Total-Count, X-Request-Id"></div>
</fieldset>
<fieldset>
<legend>Headers de la respuesta real</legend>
<div class="field"><label>Allow-Origin</label><input type="text" id="p_acao" list="acaoPresets" placeholder="(header omitido)">
<datalist id="acaoPresets">
<option value="*"></option><option value="echo"></option>
<option value="${PAGE}"></option><option value="${PAGE}/"></option>
<option value="http://evil.example.com"></option>
<option value="${PAGE}, http://localhost:4000"></option>
</datalist></div>
<div class="field check"><label>Allow-Credentials</label><input type="checkbox" id="p_acac"></div>
<div class="field"><label>Expose-Headers</label><input type="text" id="p_aceh" placeholder="(omitido) probá: * o X-Total-Count"></div>
<div class="field check"><label>Vary: Origin</label><input type="checkbox" id="p_vary" checked></div>
<div class="field"><label>status</label><input type="number" id="p_status" value="200"></div>
<div class="field check"><label>sin CORS si ≥400</label><input type="checkbox" id="p_errnocors"></div>
<div class="field"><label>demora (ms)</label><input type="number" id="p_delay" value="0" step="100"></div>
</fieldset>
<fieldset>
<legend>Headers del preflight</legend>
<div class="field"><label>Allow-Methods</label><input type="text" id="p_acam" placeholder="(omitido) probá: echo o PUT,DELETE"></div>
<div class="field"><label>Allow-Headers</label><input type="text" id="p_acah" placeholder="(omitido) probá: echo o *"></div>
<div class="field"><label>Max-Age (s)</label><input type="text" id="p_acma" placeholder="(omitido)"></div>
<div class="field"><label>status del OPTIONS</label><input type="number" id="p_pfstatus" value="204"></div>
<div class="field check"><label>sin CORS en OPTIONS</label><input type="checkbox" id="p_pfcors"></div>
<div class="field check"><label>omitir Allow-Origin</label><input type="checkbox" id="p_pfacao"></div>
<div class="field check"><label>omitir Allow-Creds</label><input type="checkbox" id="p_pfacac"></div>
<div class="field check"><label>redirigir OPTIONS</label><input type="checkbox" id="p_pfredirect"></div>
<div class="field check"><label>URL estable (caché)</label><input type="checkbox" id="p_stable" title="Reutiliza la misma URL para que el navegador pueda cachear el preflight"></div>
</fieldset>
</div>
<div class="cols" style="margin-top:.9rem">
<div class="panel"><h4>El servidor va a mandar</h4><pre id="pgServer"></pre></div>
<div class="panel"><h4>La página va a ejecutar</h4><pre id="pgClient"></pre></div>
</div>
<div class="pg-actions">
<button class="primary" id="pgRun">Probar</button>
<span class="watch" id="pgVerdict"></span>
</div>
<div class="result" id="pgResult"></div>`;
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 <b>va a tener preflight</b> — ${esc(reason)}.`
: s.client.mode === 'no-cors'
? `Modo no-cors: sin preflight, y los headers fuera de la safelist se descartan.`
: `Esto es un <b>request simple</b> — 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';
};

292
public/index.html Normal file
View file

@ -0,0 +1,292 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laboratorio de CORS</title>
<style>
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--panel-2: #f1f3f6;
--ink: #14181f;
--ink-2: #4a5462;
--ink-3: #78828f;
--line: #dde1e7;
--accent: #2563eb;
--ok: #157f4a;
--ok-bg: #e6f5ec;
--bad: #c0281f;
--bad-bg: #fdecea;
--warn: #8a5a00;
--warn-bg: #fdf3e0;
--code-bg: #f4f6f9;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--radius: 10px;
--scale: 1;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c222b;
--ink: #e8ecf1;
--ink-2: #a8b3c0;
--ink-3: #7d8896;
--line: #2a313b;
--accent: #6a9bff;
--ok: #62d09a;
--ok-bg: #12291e;
--bad: #ff8c80;
--bad-bg: #2d1715;
--warn: #ffc76b;
--warn-bg: #2c2210;
--code-bg: #10151b;
}
}
:root[data-theme="dark"] {
--bg: #0e1116; --panel: #161b22; --panel-2: #1c222b; --ink: #e8ecf1;
--ink-2: #a8b3c0; --ink-3: #7d8896; --line: #2a313b; --accent: #6a9bff;
--ok: #62d09a; --ok-bg: #12291e; --bad: #ff8c80; --bad-bg: #2d1715;
--warn: #ffc76b; --warn-bg: #2c2210; --code-bg: #10151b;
}
* { box-sizing: border-box; }
html { font-size: calc(16px * var(--scale)); }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--sans); line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
code, pre, .mono { font-family: var(--mono); }
a { color: var(--accent); }
/* ---------- header ---------- */
header.top {
position: sticky; top: 0; z-index: 30;
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 0.7rem 1rem;
}
.top-row { display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: center; }
.brand { font-weight: 650; font-size: 1.05rem; letter-spacing: -0.01em; margin-right: auto; }
.brand span { color: var(--ink-3); font-weight: 400; font-size: 0.85rem; margin-left: 0.5rem; }
.origins { display: flex; flex-wrap: wrap; gap: 0.4rem; font-size: 0.75rem; }
.chip {
font-family: var(--mono); background: var(--panel-2); border: 1px solid var(--line);
padding: 0.15rem 0.45rem; border-radius: 6px; white-space: nowrap;
}
.chip b { color: var(--ink-3); font-weight: 500; }
.chip.live { border-color: var(--ok); color: var(--ok); }
.chip.dead { border-color: var(--bad); color: var(--bad); }
button, select, input, textarea {
font: inherit; font-size: 0.85rem; color: var(--ink);
background: var(--panel); border: 1px solid var(--line);
border-radius: 7px; padding: 0.3rem 0.6rem;
}
button { cursor: pointer; }
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: #fff; border-color: transparent; font-weight: 550; }
button.primary:hover { filter: brightness(1.08); }
button.run { font-weight: 550; }
.toolbar { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; }
nav.groups { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.6rem; font-size: 0.8rem; }
nav.groups a { text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 6px; background: var(--panel-2); color: var(--ink-2); }
nav.groups a:hover { color: var(--accent); }
/* ---------- layout ---------- */
.wrap { display: grid; grid-template-columns: minmax(0,1fr) 25rem; gap: 1.2rem; padding: 1.2rem 1rem 6rem; max-width: 96rem; margin: 0 auto; }
@media (max-width: 1100px) { .wrap { grid-template-columns: minmax(0,1fr); } aside.log { position: static; max-height: 22rem; } }
/* ---------- intro ---------- */
.intro { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1.2rem; }
.intro h1 { margin: 0 0 0.4rem; font-size: 1.25rem; letter-spacing: -0.02em; }
.intro p { margin: 0.4rem 0; color: var(--ink-2); font-size: 0.92rem; max-width: 62ch; }
.intro ol { margin: 0.5rem 0 0; padding-left: 1.3rem; color: var(--ink-2); font-size: 0.92rem; }
/* ---------- groups & cards ---------- */
section.group { margin-bottom: 2rem; scroll-margin-top: 7rem; }
section.group > h2 { font-size: 1.1rem; margin: 0 0 0.15rem; letter-spacing: -0.015em; }
section.group > p.blurb { margin: 0 0 0.9rem; color: var(--ink-3); font-size: 0.88rem; }
article.card {
background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--line);
border-radius: var(--radius); padding: 0.9rem 1rem; margin-bottom: 0.8rem;
}
article.card[data-expect="pass"] { border-left-color: var(--ok); }
article.card[data-expect="fail"] { border-left-color: var(--bad); }
article.card[data-expect="partial"] { border-left-color: var(--warn); }
article.card[data-expect="special"] { border-left-color: var(--accent); }
.card-head { display: flex; gap: 0.6rem; align-items: baseline; flex-wrap: wrap; }
.card-head h3 { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }
.num { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-3); }
.badge {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 650;
padding: 0.1rem 0.45rem; border-radius: 5px; white-space: nowrap;
}
.badge.pass { background: var(--ok-bg); color: var(--ok); }
.badge.fail { background: var(--bad-bg); color: var(--bad); }
.badge.partial { background: var(--warn-bg); color: var(--warn); }
.badge.special { background: var(--panel-2); color: var(--accent); }
.badge.neutral { background: var(--panel-2); color: var(--ink-2); }
.why { margin: 0.5rem 0 0.7rem; font-size: 0.9rem; color: var(--ink-2); max-width: 78ch; }
.why code { background: var(--code-bg); padding: 0.05em 0.3em; border-radius: 4px; font-size: 0.86em; }
.why strong { color: var(--ink); }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
.panel { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.panel h4 {
margin: 0; padding: 0.35rem 0.6rem; font-size: 0.68rem; font-weight: 650;
text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3);
border-bottom: 1px solid var(--line); background: var(--panel-2);
}
.panel pre { margin: 0; padding: 0.55rem 0.6rem; font-size: 0.775rem; line-height: 1.5; overflow-x: auto; white-space: pre; }
.panel pre .k { color: var(--accent); }
.panel pre .c { color: var(--ink-3); }
.panel pre .s { color: var(--ok); }
.panel pre .hl { background: var(--warn-bg); border-radius: 3px; }
.actions { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.7rem; flex-wrap: wrap; }
.watch { font-size: 0.8rem; color: var(--ink-3); }
.watch b { color: var(--ink-2); font-weight: 600; }
/* ---------- results ---------- */
.result { margin-top: 0.7rem; display: none; }
.result.shown { display: block; }
.verdict { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; font-size: 0.85rem; margin-bottom: 0.5rem; }
.verdict .big { font-weight: 650; }
.verdict .big.ok { color: var(--ok); }
.verdict .big.blocked { color: var(--bad); }
.meta { color: var(--ink-3); font-size: 0.78rem; font-family: var(--mono); }
.surprise { background: var(--warn-bg); color: var(--warn); border-radius: 5px; padding: 0.1rem 0.4rem; font-size: 0.72rem; font-weight: 650; }
.errbox { background: var(--bad-bg); color: var(--bad); border-radius: 7px; padding: 0.5rem 0.65rem; font-family: var(--mono); font-size: 0.78rem; white-space: pre-wrap; }
.kv { font-family: var(--mono); font-size: 0.78rem; }
.kv div { padding: 0.1rem 0; }
.kv .null { color: var(--bad); }
.kv .val { color: var(--ok); }
details.more { margin-top: 0.5rem; }
details.more > summary { cursor: pointer; font-size: 0.8rem; color: var(--ink-3); }
details.more > summary:hover { color: var(--accent); }
details.more pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 7px; padding: 0.5rem 0.6rem; font-size: 0.75rem; overflow-x: auto; margin: 0.4rem 0 0; }
.trace { margin-top: 0.5rem; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.trace-row { display: flex; gap: 0.5rem; padding: 0.3rem 0.55rem; font-family: var(--mono); font-size: 0.76rem; border-top: 1px solid var(--line); align-items: baseline; }
.trace-row:first-child { border-top: 0; }
.trace-row.pre { background: var(--warn-bg); }
.trace-row .m { font-weight: 650; min-width: 4.8rem; }
.trace-row .st { margin-left: auto; color: var(--ink-3); }
.trace-empty { padding: 0.4rem 0.55rem; font-size: 0.78rem; color: var(--ink-3); }
/* ---------- server log ---------- */
aside.log { position: sticky; top: 6.5rem; align-self: start; max-height: calc(100vh - 8rem); display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
aside.log header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--line); background: var(--panel-2); }
aside.log header h2 { margin: 0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3); margin-right: auto; }
.logbody { overflow-y: auto; padding: 0.3rem; font-family: var(--mono); font-size: 0.74rem; }
.entry { padding: 0.35rem 0.45rem; border-radius: 6px; margin-bottom: 0.25rem; background: var(--panel-2); border-left: 2px solid var(--line); }
.entry.preflight { border-left-color: var(--warn); }
.entry.actual { border-left-color: var(--accent); }
.entry .line1 { display: flex; gap: 0.4rem; align-items: baseline; }
.entry .line1 .m { font-weight: 650; }
.entry .line1 .s { margin-left: auto; }
.entry .s.s2 { color: var(--ok); } .entry .s.s3 { color: var(--warn); }
.entry .s.s4, .entry .s.s5 { color: var(--bad); }
.entry .sub { color: var(--ink-3); word-break: break-all; }
.entry .sent { color: var(--ok); }
.entry .none { color: var(--bad); }
.hint { padding: 0.6rem; color: var(--ink-3); font-size: 0.78rem; font-family: var(--sans); }
/* ---------- special demos ---------- */
.demo-row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; margin-top: 0.6rem; }
.demo-box { border: 1px dashed var(--line); border-radius: 8px; padding: 0.6rem; font-size: 0.78rem; color: var(--ink-3); }
.demo-box img { display: block; max-width: 220px; height: 60px; }
iframe.sink { width: 100%; height: 6.5rem; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); margin-top: 0.5rem; }
/* ---------- playground ---------- */
.pg { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
.pg h2 { margin: 0 0 0.2rem; font-size: 1.1rem; }
.pg > p { margin: 0 0 0.9rem; color: var(--ink-3); font-size: 0.88rem; }
.pg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 0.9rem; }
fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.7rem; margin: 0; }
legend { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3); font-weight: 650; padding: 0 0.3rem; }
.field { display: grid; grid-template-columns: 8.5rem 1fr; gap: 0.4rem; align-items: center; margin-bottom: 0.35rem; font-size: 0.8rem; }
.field label { color: var(--ink-2); font-family: var(--mono); font-size: 0.75rem; }
.field input[type="text"], .field input[type="number"], .field select, .field textarea { width: 100%; }
.field textarea { min-height: 3.4rem; font-family: var(--mono); font-size: 0.75rem; resize: vertical; }
.field.check { grid-template-columns: 8.5rem 1fr; }
.field.check input { justify-self: start; }
.pg-actions { margin-top: 0.9rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.footnote { color: var(--ink-3); font-size: 0.78rem; margin-top: 1.2rem; max-width: 70ch; }
</style>
</head>
<body>
<header class="top">
<div class="top-row">
<div class="brand">Laboratorio de CORS <span>todas las configuraciones, una sola página</span></div>
<div class="origins" id="origins"></div>
<div class="toolbar">
<button id="cookieSet" title="Abre :3001 en una pestaña nueva y deja las cookies ahí">Poner cookies en la API</button>
<button id="runAll" class="primary">Probar todo</button>
<button id="clearAll">Limpiar</button>
<button id="fontDown" title="Más chico">A−</button>
<button id="fontUp" title="Más grande">A+</button>
<button id="theme" title="Cambiar tema">◐</button>
</div>
</div>
<nav class="groups" id="nav"></nav>
</header>
<div class="wrap">
<main>
<div class="intro">
<h1>Qué es esta página</h1>
<p>
Esta página se sirve desde <code id="pageOrigin"></code>. Hace requests a una API en
<code id="apiOrigin"></code> — un <strong>origen distinto</strong>, 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.
</p>
<p>
El panel de la derecha es el <strong>log del propio servidor</strong>, 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.
</p>
<ol>
<li>Abrí DevTools → Network y dejalo a la vista. Todas las respuestas bloqueadas están ahí.</li>
<li>Hacé clic una vez en <b>Poner cookies en la API</b>, así los escenarios de credenciales tienen algo para mandar.</li>
<li>Recorré los grupos de arriba abajo, o tocá <b>Probar todo</b> y comentá el resumen.</li>
</ol>
</div>
<div id="scenarios"></div>
<section class="group" id="g-playground">
<h2>6 · Laboratorio libre</h2>
<p class="blurb">Todas las perillas, para improvisar cuando alguien pregunta "¿y si…?".</p>
<div class="pg" id="pg"></div>
</section>
<p class="footnote">
El código del servidor está en <code>server.js</code>: unas 330 líneas de Node puro, sin dependencias, escritas para
leerse en voz alta. La lista de escenarios son datos en <code>public/scenarios.js</code>, así que podés agregar los tuyos.
</p>
</main>
<aside class="log">
<header>
<h2>Log del servidor</h2>
<span class="chip" id="logStatus">conectando…</span>
<button id="logClear">Limpiar</button>
</header>
<div class="logbody" id="logBody">
<div class="hint">Acá aparece cada request que recibe la API — incluidos los preflights que el navegador mandó por su cuenta, y los requests cuyas respuestas el navegador se negó a darle a JavaScript.</div>
</div>
</aside>
</div>
<script src="scenarios.js"></script>
<script src="app.js"></script>
</body>
</html>

506
public/scenarios.js Normal file
View file

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

407
server.js Normal file
View file

@ -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=<literal> 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=<literal> 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(
`<!doctype html><meta charset="utf-8"><title>Cookies set</title>
<body style="font:16px/1.6 system-ui;padding:2rem;max-width:40rem">
<h1>Cookies set on <code>${url.host}</code></h1>
<p>Set: <code>cors_demo_lax</code> (SameSite=Lax) and
<code>cors_demo_strict</code> (SameSite=Strict).</p>
<p>Go back to the test page and run the credentials scenarios.</p>
<p><a href="/api/cookie/clear">Clear them</a></p>`
);
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('<!doctype html><meta charset="utf-8"><body style="font:16px system-ui;padding:2rem">Cookies cleared. <a href="/api/cookie/set">Set again</a>');
return;
}
/* --- a real image, to show that <img> 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(
`<svg xmlns="http://www.w3.org/2000/svg" width="220" height="60">
<rect width="220" height="60" rx="8" fill="#2563eb"/>
<text x="110" y="36" font-family="system-ui" font-size="15" fill="#fff" text-anchor="middle">loaded from :3001</text>
</svg>`
);
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('');
});
});