Lesson 03 / 16
Cross-Origin Resource Sharing
The server-side configuration of cross-origin sharing: how many of seven origins each of four settings lets the response be used for, how many origins a wildcard setting opens, how a wildcard setting on a credentialed request returns 200 on the server while being silently dropped on the client, the round-trip cost of a preflight request, and how many endpoints carry the policy.
Contents
The previous two lessons looked at the request’s body. This lesson looks at where the request came from and measures the one thing the server can say about it: the response headers.
The client side of cross-origin sharing — which request the browser blocks and why, the effect of the policy on the page — was built in the Frontend Quality course and is not repeated here. What this lesson measures is the server-side configuration: which layer the setting sits in, how many origins a wildcard value opens the door to, what a wrong setting does on a request carrying credentials, and what a preflight request costs.
The server’s job is small: it looks at the incoming origin and either adds a few response headers or does not. The decision is made by the client. This distinction is the point every measurement in this lesson rests on.
AS5 (assumption): the client’s rule was written here, for this lesson. The correctness of the server setting is measured by whether the response is used according to that rule; the measurement is the outcome of the rule’s implementation, not of a real client.
The Server’s One Statement
// policy.mjs — the server's one statement on cross-origin sharing: response headers export const SETTING = { origins: ["https://loans.library", "https://panel.library"], // "*" means wildcard methods: ["GET", "POST"], headers: ["content-type", "x-loan-trace"], credentials: false, preflightMaxAge: 600, }; export function headers(request, setting) { const h = {}; if (!request.origin) return h; // same-origin request; no statement needed const wildcard = setting.origins === "*"; if (!wildcard && !setting.origins.includes(request.origin)) return h; // silence = reject h["access-control-allow-origin"] = wildcard ? "*" : request.origin; if (setting.credentials) h["access-control-allow-credentials"] = "true"; if (!wildcard) h["vary"] = "origin"; // the response varies by origin if (request.method === "OPTIONS") { h["access-control-allow-methods"] = setting.methods.join(","); h["access-control-allow-headers"] = setting.headers.join(","); h["access-control-max-age"] = String(setting.preflightMaxAge); } return h; }
// client.mjs — the decision a compliant client makes by reading the response headers // The rule is written here for this lesson; the server setting's correctness is measured against it. export function decide(request, h) { if (!request.origin) return { used: true, reason: "same origin" }; const allow = h["access-control-allow-origin"]; if (!allow) return { used: false, reason: "no allow header" }; if (allow !== "*" && allow !== request.origin) return { used: false, reason: "origin mismatch" }; if (request.credentials) { if (allow === "*") return { used: false, reason: "wildcard + credentials" }; if (h["access-control-allow-credentials"] !== "true") return { used: false, reason: "no credentials permission" }; } return { used: true, reason: "" }; }
The policy layer’s rejection is not an error, it is silence: it adds no header at all to an origin it does not recognize. The server still returns 200, and the body is still written. This design decision is the source of every silent outcome in the rest of the lesson.
The Origins a Wildcard Setting Opens
Seven origins are taken: the loan system’s own interface, the internal panel, the same name with a different scheme and port, a subdomain, an unrelated origin, and a same-origin request that carries no origin header at all.
// origin.mjs — seven origins, four settings; what the server returns versus what the client uses import { SETTING, headers } from "./policy.mjs"; import { decide } from "./client.mjs"; const ORIGINS = [ null, // same origin (no header) "https://loans.library", // its own interface "https://panel.library", // internal panel "http://loans.library", // different scheme "https://loans.library:8443", // different port "https://sub.loans.library", // subdomain "https://other.site", // unrelated ]; const SETTINGS = [ ["list, no credentials", { ...SETTING }], ["wildcard, no credentials", { ...SETTING, origins: "*" }], ["wildcard, credentials", { ...SETTING, origins: "*", credentials: true }], ["list, credentials", { ...SETTING, credentials: true }], ]; const passes = (setting, origin, credentials) => { const request = { origin, method: "GET", credentials }; return decide(request, headers(request, setting)).used; }; console.log(`${ORIGINS.length} origins (1 same origin + ${ORIGINS.length - 1} external origins), ${SETTINGS.length} settings`); console.log(`\n${"setting".padEnd(24)}${"server 2xx".padStart(11)}${"client used: no credentials".padStart(29)}${"credentials".padStart(13)}`); for (const [name, setting] of SETTINGS) { const k0 = ORIGINS.filter((o) => passes(setting, o, false)).length; const k1 = ORIGINS.filter((o) => passes(setting, o, true)).length; console.log(name.padEnd(24) + String(ORIGINS.length).padStart(11) + String(k0).padStart(29) + String(k1).padStart(13)); } const wildcardOpen = ORIGINS.filter((o) => o && passes({ ...SETTING, origins: "*" }, o, false)); const listOpen = ORIGINS.filter((o) => o && passes(SETTING, o, false)); console.log(`\nexternal origins the list setting opens: ${listOpen.length} (${listOpen.join(", ")})`); console.log(`external origins the wildcard setting opens in this set: ${wildcardOpen.length}; no limit outside the set`); console.log(`\nrequests carrying credentials under the 'wildcard, credentials' setting:`); for (const o of ORIGINS.slice(1, 4)) { const request = { origin: o, method: "GET", credentials: true }; const h = headers(request, { ...SETTING, origins: "*", credentials: true }); const c = decide(request, h); console.log(` ${o.padEnd(30)} server 200 + ${Object.keys(h).length} headers -> client: ${c.used ? "used" : "dropped (" + c.reason + ")"}`); }
7 origins (1 same origin + 6 external origins), 4 settings setting server 2xx client used: no credentials credentials list, no credentials 7 3 1 wildcard, no credentials 7 7 1 wildcard, credentials 7 7 1 list, credentials 7 3 3 external origins the list setting opens: 2 (https://loans.library, https://panel.library) external origins the wildcard setting opens in this set: 6; no limit outside the set requests carrying credentials under the 'wildcard, credentials' setting: https://loans.library server 200 + 2 headers -> client: dropped (wildcard + credentials) https://panel.library server 200 + 2 headers -> client: dropped (wildcard + credentials) http://loans.library server 200 + 2 headers -> client: dropped (wildcard + credentials)
The server 2xx column is seven in all four settings. The server rejects no request; the setting
only decides whether the client uses the response. The list setting is open to two external
origins; the wildcard setting is open to six in this set, and there is no limit outside the
set — every origin whose name was never written down gets in, scheme difference, port
difference, and subdomain included. The difference between wildcard and list in this row is 2
against 6; in reality it is 2 against unlimited.
The third row is this lesson’s silent setting. credentials is turned on, the server sends both
headers, the request returns 200 — and the client drops the response. The wildcard, credentials row is identical to the wildcard, no credentials row: turning the setting on
changed nothing. In a log pile, these two settings cannot be told apart; both cases have 200, no
error, no latency. Only the internal panel fails to work, and the reason is invisible on the
server side.
The correct setting is the last row: an origin list and credentials permission together. The three credentialed requests are used only there.
The Cost of Preflight
The policy layer is measured in a real process. A request carrying a custom header requires a preflight request before it is sent; the preflight’s result can be cached for a lifetime.
// server.mjs — local process listening on the loan endpoints; the policy layer sits in front of the endpoints import http from "node:http"; import { headers } from "./policy.mjs"; export const ENDPOINTS = ["/catalog", "/loans", "/member", "/report", "/archive"]; export function start(setting, covered = ENDPOINTS) { const counter = { request: 0, preflight: 0, bytes: 0 }; const s = http.createServer((q, y) => { counter.request++; if (q.method === "OPTIONS") counter.preflight++; const request = { origin: q.headers.origin ?? null, method: q.method }; const h = covered.includes(q.url) ? headers(request, setting) : {}; const body = q.method === "OPTIONS" ? "" : JSON.stringify({ endpoint: q.url, status: "ok" }); counter.bytes += body.length + Object.entries(h).reduce((t, [k, v]) => t + k.length + v.length + 4, 0); y.writeHead(q.method === "OPTIONS" ? 204 : 200, { "content-type": "application/json", ...h }); y.end(body); }); return new Promise((c) => s.listen(0, "127.0.0.1", () => c({ port: s.address().port, counter, close: () => s.close() }))); }
// measurement.mjs — real requests: the cost of preflight and the measure of the layer's coverage import { start, ENDPOINTS } from "./server.mjs"; import { SETTING } from "./policy.mjs"; import { decide } from "./client.mjs"; const ORIGIN = "https://panel.library"; const N = 6; // Compliant client: a request with a custom header asks for a preflight first, and the result is cached for its lifetime. async function send(port, endpoint, { custom, cache, credentials = false }) { const base = `http://127.0.0.1:${port}`; if (custom && !(cache && cache.has(endpoint))) { const o = await fetch(base + endpoint, { method: "OPTIONS", headers: { origin: ORIGIN } }); if (cache) cache.set(endpoint, Number(o.headers.get("access-control-max-age") ?? 0)); } const headers = { origin: ORIGIN, ...(custom ? { "x-loan-trace": "1" } : {}) }; const y = await fetch(base + endpoint, { headers }); const h = Object.fromEntries(y.headers); return { status: y.status, decision: decide({ origin: ORIGIN, credentials }, h) }; } const { port, counter, close } = await start(SETTING); const run = async (name, option) => { const before = { ...counter }; for (let i = 0; i < N; i++) await send(port, "/catalog", option); console.log(`${name.padEnd(28)}${String(counter.request - before.request).padStart(8)}` + `${String(counter.preflight - before.preflight).padStart(12)}${String(counter.bytes - before.bytes).padStart(9)}`); }; console.log(`${N} real requests, one endpoint\n`); console.log(`${"request shape".padEnd(28)}${"round trip".padStart(8)}${"preflight".padStart(12)}${"bytes".padStart(9)}`); await run("plain (no preflight needed)", { custom: false }); await run("custom header, no cache", { custom: true }); await run("custom header, cached", { custom: true, cache: new Map() }); console.log(`preflight lifetime: ${SETTING.preflightMaxAge} s -> 1 preflight per endpoint per lifetime`); close(); // Layer coverage: the policy is registered on four of five endpoints. const MISSING = ENDPOINTS.filter((u) => u !== "/archive"); const second = await start({ ...SETTING, credentials: true }, MISSING); console.log(`\npolicy registered on ${MISSING.length}/${ENDPOINTS.length} endpoints\n`); console.log(`${"endpoint".padEnd(10)}${"server status".padStart(14)}${"client".padStart(9)} reason`); let dropped = 0; for (const endpoint of ENDPOINTS) { const r = await send(second.port, endpoint, { custom: false, credentials: true }); if (!r.decision.used) dropped++; console.log(endpoint.padEnd(10) + String(r.status).padStart(14) + (r.decision.used ? "used" : "dropped").padStart(9) + " " + (r.decision.reason || "-")); } console.log(`\n${dropped} of ${ENDPOINTS.length} endpoints had their response dropped by the client; errors returned by the server: 0`); second.close();
6 real requests, one endpoint request shape round trip preflight bytes plain (no preflight needed) 6 0 618 custom header, no cache 12 6 1770 custom header, cached 7 1 810 preflight lifetime: 600 s -> 1 preflight per endpoint per lifetime policy registered on 4/5 endpoints endpoint server status client reason /catalog 200 used - /loans 200 used - /member 200 used - /report 200 used - /archive 200 dropped no allow header 1 of 5 endpoints had their response dropped by the client; errors returned by the server: 0
The cost of preflight is a number independent of the run: six custom requests without a cache make twelve round trips, twice as many. Once the lifetime setting takes effect, the count drops to seven — one preflight per endpoint. Policy bytes nearly triple too: 618 against 1770.
This cost belongs only to requests with a custom header. The first row shows this: six requests without a custom header produce no preflight at all. The way to avoid preflight is not to loosen the setting, but to simplify the shape of the request.
Where the Setting Sits
The last table is the layer question. The policy is registered on four of five endpoints; the
/archive endpoint was forgotten. That endpoint accepts requests, writes the body, and
returns 200. The response is dropped on the client, and there is no trace of it on the server
side: the error count is zero, the status code is the same as the other four endpoints.
How many places the setting is repeated in directly determines this risk. If the policy is
registered separately on every endpoint handler, there are five write sites, and a sixth endpoint
needs a sixth write site added by hand. If the policy sits as a single layer in front of the
endpoints, there is one write site and a new endpoint is covered automatically — the covered
list inside server.mjs is exactly the difference between these two choices.
Summary
- The server’s one statement on cross-origin sharing is the response headers; its rejection is not an error, it is silence. In all four settings, the server returned 2xx to all seven of the seven requests.
- The origin list is open to 2 external origins; the wildcard setting is open to 6 origins in the measured set and to an unlimited number outside it — scheme difference, port difference, and subdomain included.
- Adding credentials permission to the wildcard setting changed nothing: the
wildcard, credentialsrow was identical towildcard, no credentials. The server sent both headers, returned 200, and the client dropped the response. The correct setting uses an origin list together with credentials permission. - The cost of preflight is independent of the run: 6 custom requests without a cache make 12 round trips and 1770 bytes; with the lifetime setting, 7 round trips and 810 bytes; 6 requests without a custom header make 0 preflights.
- With the policy registered on 4 of 5 endpoints, the response from the skipped endpoint was dropped on the client and the server showed 0 errors. Per-endpoint registration repeats at 5 points; a single layer in front of the endpoints stops at 1 point and covers a new endpoint automatically.
Next Step
The cross-origin sharing setting was nothing more than a set of response headers, and the measurement showed that the real question is which layer produces the header. The same question applies to the other headers a response carries: the ones that say how the connection should be established, how the content should be interpreted, and what the browser may load can just as well be scattered endpoint by endpoint or placed from a single site. The next lesson counts these headers: how many endpoints they repeat on, how many files moving them to a single layer touches, what behavior a missing header silently leaves open, and where the point at which the encrypted connection terminates fits into this picture.
To keep your progress and take notes, Log in
My notes
Log in to take notes.