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

Cookies set on ${url.host}

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

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

Clear them

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