cors-demo/server.js

408 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-10-01 13:02:44 -03:00
/*
* 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('');
});
});