cors-demo/README.md
2026-10-01 13:02:44 -03:00

4.3 KiB

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.

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.