407 lines
14 KiB
JavaScript
407 lines
14 KiB
JavaScript
/*
|
|
* 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('');
|
|
});
|
|
});
|