init
This commit is contained in:
commit
4b4b458584
7 changed files with 2025 additions and 0 deletions
81
README.md
Normal file
81
README.md
Normal 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
9
package.json
Normal 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
33
public/_selftest.html
Normal 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
697
public/app.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
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><img src="${esc(API)}/api/image"></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><img crossorigin="anonymous" …></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á "Poner cookies en la API"">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 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
292
public/index.html
Normal 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
506
public/scenarios.js
Normal 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: '<img>: sin CORS, sin Origin, sin problema',
|
||||||
|
expect: 'special',
|
||||||
|
special: 'img',
|
||||||
|
why:
|
||||||
|
'Las imágenes, los scripts, las hojas de estilo y los iframes vienen cargándose entre orígenes desde 1995 y no necesitan CORS. La imagen de abajo se renderiza desde :3001 sin ningún header CORS, y el request ni siquiera lleva header Origin. ' +
|
||||||
|
'CORS gobierna qué puede leer el <em>script</em>, no qué puede descargar el navegador. (Agregale <code>crossorigin</code> a la etiqueta y las reglas de CORS sí aplican: así es como se leen los píxeles de un canvas.)',
|
||||||
|
watch: 'El log muestra un request sin Origin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'form-post',
|
||||||
|
group: 'failures',
|
||||||
|
title: 'POST de formulario entre orígenes: CORS no te protege de CSRF',
|
||||||
|
expect: 'special',
|
||||||
|
special: 'form',
|
||||||
|
why:
|
||||||
|
'Esto postea un formulario a :3001 desde esta página, entre orígenes, con las cookies adjuntas, y el servidor lo procesa. Sin un solo header CORS en ningún lado. ' +
|
||||||
|
'No se bloqueó nada porque enviar un formulario es una navegación, no una lectura desde un script: el navegador solo retiene la <em>respuesta</em>. ' +
|
||||||
|
'Así que CORS nunca te protegió de requests entre orígenes que modifican estado. Para eso están las cookies SameSite y los tokens CSRF. Si tu clase se tiene que llevar una sola cosa además de la regla del comodín, que sea esta.',
|
||||||
|
watch: 'El log muestra un POST con tus cookies adjuntas y una respuesta 200.',
|
||||||
|
},
|
||||||
|
];
|
||||||
407
server.js
Normal file
407
server.js
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue