Lesson 04 / 16
Security Headers and TLS
The server-side measure of transport-layer hardening: how many write sites six security headers occupy across ten endpoints, the eleven files touched by migrating to a single layer, the number of obligations a missing header and a written-but-ineffective value drop without producing an error, and the false accepts and false rejects a forwarded protocol header produces under three settings.
Contents
The previous lesson showed the server’s one statement on cross-origin sharing is the response headers, and the real question is which layer produces the header. This lesson carries that question to the response’s other headers and measures three things: how many endpoints repeat these headers and how many files a move to a single layer touches; what a missing or wrongly written header drops without an error; and where the application reads its “this connection is encrypted” assumption from, and what happens when that source is forged.
The Header’s Contract
A security header is a one-sentence obligation the server gives a compliant client; when it is missing, what exists is not an error but the absence of an obligation. What is measured is the sentence itself: is it written, and if so, does its value actually construct it.
// hardening.mjs — the header set const define = (value, ifMissing, rule) => ({ value, ifMissing, effective: (v) => rule.test(v.trim()) }); export const SET = { "strict-transport-security": define("max-age=31536000; includeSubDomains", "the first request can be tried unencrypted", /max-age=\s*[1-9]\d*/), "content-security-policy": define("default-src 'self'; frame-ancestors 'none'", "no resource or frame restriction", /default-src(?![^;]*\*)/), "x-content-type-options": define("nosniff", "content type can be guessed", /^nosniff$/i), "referrer-policy": define("no-referrer", "the address and query string leak outward", /^(no-referrer|same-origin|strict-origin)$/i), "x-frame-options": define("DENY", "no embedding restriction", /^(deny|sameorigin)$/i), "permissions-policy": define("geolocation=(), camera=()", "no capability restriction", /=\(\)/), }; export function audit(b) { const missing = [], ineffective = []; for (const [name, k] of Object.entries(SET)) { if (b[name] === undefined) missing.push(name); else if (!k.effective(b[name])) ineffective.push(name); } return { missing, ineffective, sound: Object.keys(SET).length - missing.length - ineffective.length }; }
The effective field carries the lesson’s second measure: a header can be absent, present and
effective, or present but ineffective. The third is counted separately since it is confused
with the second at a glance — present in the list, name correct, only the value constructs no
obligation.
AS6 (assumption): the client’s obligation is written as a contract here, not as browser
behavior; the web side was built in Frontend Quality. What is measured is the response itself: the
headers read with node:http.
How Many Endpoints It Repeats On
The loan system splits into four services exposing ten endpoints; today the headers are written separately inside every endpoint handler.
// system.mjs — endpoints and two deviations export const SERVICES = { catalog: ["search", "book", "recommend"], loan: ["borrow", "extend", "return"], member: ["profile", "penalty"], report: ["daily", "monthly"], }; export const ENDPOINTS = Object.entries(SERVICES).flatMap(([s, us]) => us.map((u) => `/${s}/${u}`)); export const MISSING = { "/report/monthly": 2 }; // last two headers not written export const WRONG = { "/loan/extend": ["x-content-type-options", "no-sniff"] };
// distribution.mjs — write sites and migration import { mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { SET } from "./hardening.mjs"; import { ENDPOINTS, MISSING, WRONG } from "./system.mjs"; const D = new URL("./ep/", import.meta.url).pathname; rmSync(D, { recursive: true, force: true }); mkdirSync(D); // each endpoint writes its own for (const endpoint of ENDPOINTS) { const [wh, wv] = WRONG[endpoint] ?? []; const lines = Object.entries(SET) .slice(0, Object.keys(SET).length - (MISSING[endpoint] ?? 0)) .map(([h, k]) => ` y.setHeader(${JSON.stringify(h)}, ${JSON.stringify(h === wh ? wv : k.value)});`); writeFileSync(D + endpoint.slice(1).replace("/", "-") + ".mjs", `export function handle(q, y) {\n${lines.join("\n")}\n y.end(JSON.stringify({ endpoint: ${JSON.stringify(endpoint)} }));\n}\n`); } const scan = () => readdirSync(D).map((f) => { const matches = [...readFileSync(D + f, "utf8").matchAll(/setHeader\("([^"]+)",\s"([^"]*)"\)/g)]; return { f, record: matches.length, sound: matches.filter(([, h, v]) => SET[h]?.effective(v)).length }; }); const before = scan(); const total = Object.keys(SET).length; console.log(`${before.length} endpoint files, ${total} headers`); console.log(`write sites: ${before.length} record lines: ${before.reduce((t, d) => t + d.record, 0)}`); console.log(`files whose set is complete and effective: ${before.filter((d) => d.sound === total).length}/${before.length}`); for (const d of before.filter((d) => d.sound !== total)) console.log(` ${d.f.padEnd(20)} record ${d.record}, effective ${d.sound}`); // migration: lines are deleted, a layer is written let touched = 0; for (const { f } of before) { const old = readFileSync(D + f, "utf8"), updated = old.replace(/^\s*y\.setHeader\(.*\n/gm, ""); if (updated !== old) { writeFileSync(D + f, updated); touched++; } } const layer = Object.entries(SET) .map(([h, k]) => ` y.setHeader(${JSON.stringify(h)}, ${JSON.stringify(k.value)});`).join("\n"); writeFileSync(D + "layer.mjs", `export function harden(q, y) {\n${layer}\n}\n`); touched++; const after = scan().filter((d) => d.f !== "layer.mjs"); console.log(`\nfiles touched by the migration: ${touched} (${before.length} endpoints + 1 layer)`); console.log(`write sites after migration: 1 record lines remaining at endpoints: ${after.reduce((t, d) => t + d.record, 0)}`); console.log(`header lines to write when a new endpoint is added: ${total} per endpoint, 0 in a single layer`);
10 endpoint files, 6 headers write sites: 10 record lines: 58 files whose set is complete and effective: 8/10 loan-extend.mjs record 6, effective 5 report-monthly.mjs record 4, effective 4 files touched by the migration: 11 (10 endpoints + 1 layer) write sites after migration: 1 record lines remaining at endpoints: 0 header lines to write when a new endpoint is added: 6 per endpoint, 0 in a single layer
Ten endpoints, ten write sites. The record-line count that should be sixty is fifty-eight:
/report/monthly fell two headers short. At /loan/extend, the value was written as no-sniff
— the header is there, name correct, value an undefined token. Eight of ten files are complete.
Both deviations trace back to the write-site count: six headers written in ten separate places
means ten opportunities to deviate, and none announces itself.
The cost of migration is one-time: eleven files. After it, there is one write site, and the header line count for an eleventh endpoint is zero; per-endpoint registration demands six lines per new endpoint.
Missing Header and Ineffective Value
How many places it was written was measured in the files; what shows up in the response is a separate question, read from a real process.
// measurement.mjs — response headers import http from "node:http"; import { SET, audit } from "./hardening.mjs"; import { ENDPOINTS, MISSING, WRONG } from "./system.mjs"; const full = () => Object.fromEntries(Object.entries(SET).map(([h, k]) => [h, k.value])); const SETTINGS = [ ["per-endpoint record", (endpoint) => { const b = full(), [wh, wv] = WRONG[endpoint] ?? []; for (const h of Object.keys(b).slice(Object.keys(b).length - (MISSING[endpoint] ?? 0))) delete b[h]; if (wh) b[wh] = wv; return b; }], ["single layer", () => full()], ["single layer, wrong value", () => ({ ...full(), "strict-transport-security": "max-age=0" })], ]; const start = (produce) => new Promise((c) => { const s = http.createServer((q, y) => { y.writeHead(200, { "content-type": "application/json", ...produce(q.url) }); y.end(JSON.stringify({ endpoint: q.url })); }); s.listen(0, "127.0.0.1", () => c(s)); }); const req = (port, endpoint) => new Promise((c) => http.get({ host: "127.0.0.1", port, path: endpoint }, (y) => { y.resume(); c(y); })); console.log(`${ENDPOINTS.length} endpoints, ${Object.keys(SET).length} headers, ${ENDPOINTS.length} requests per setting\n`); console.log(`${"setting".padEnd(25)}${"200".padStart(5)}${"errors".padStart(7)}${"bytes".padStart(7)}` + `${"sound endpoints".padStart(19)}${"missing".padStart(9)}${"ineffective".padStart(13)}`); const detail = []; for (const [name, produce] of SETTINGS) { const s = await start(produce); let [ok200, bytes, soundEndpoints, missing, ineffective] = [0, 0, 0, 0, 0]; for (const endpoint of ENDPOINTS) { const y = await req(s.address().port, endpoint); if (y.statusCode === 200) ok200++; for (const h of Object.keys(SET)) if (y.headers[h]) bytes += h.length + y.headers[h].length + 4; const d = audit(y.headers); if (d.sound === Object.keys(SET).length) soundEndpoints++; else detail.push([name, endpoint, d]); missing += d.missing.length; ineffective += d.ineffective.length; } s.close(); console.log(name.padEnd(25) + String(ok200).padStart(5) + "0".padStart(7) + String(bytes).padStart(7) + `${soundEndpoints}/${ENDPOINTS.length}`.padStart(19) + String(missing).padStart(9) + String(ineffective).padStart(13)); } console.log(`\nendpoints whose set fell short, and the silent outcome:`); for (const [name, endpoint, d] of detail.slice(0, 4)) { const note = ["missing", "ineffective"].flatMap((t) => d[t].map((h) => `${t} ${h} -> ${SET[h].ifMissing}`)); console.log(` ${name.padEnd(27)}${endpoint.padEnd(17)}${note.join("; ")}`); } console.log(` ... in the "single layer, wrong value" setting, the same line repeats on ${ENDPOINTS.length} of ${ENDPOINTS.length} endpoints`);
10 endpoints, 6 headers, 10 requests per setting setting 200 errors bytes sound endpoints missing ineffective per-endpoint record 10 0 2591 8/10 2 1 single layer 10 0 2660 10/10 0 0 single layer, wrong value 10 0 2400 0/10 0 10 endpoints whose set fell short, and the silent outcome: per-endpoint record /loan/extend ineffective x-content-type-options -> content type can be guessed per-endpoint record /report/monthly missing x-frame-options -> no embedding restriction; missing permissions-policy -> no capability restriction single layer, wrong value /catalog/search ineffective strict-transport-security -> the first request can be tried unencrypted single layer, wrong value /catalog/book ineffective strict-transport-security -> the first request can be tried unencrypted ... in the "single layer, wrong value" setting, the same line repeats on 10 of 10 endpoints
All thirty of thirty requests returned 200; the error count is zero across all three settings. The only quantitative difference is hardening bytes: 2591, 2660, 2400 — and the setting producing the fewest bytes is the one whose set is complete on zero of ten endpoints. Response size gives no ranking of correctness.
per-endpoint record carries the file-level deviation into the response: two missing obligations,
one ineffective value, eight sound endpoints. single layer zeroes it out. The third row is the
other face of a single layer: the header is present on ten of ten endpoints, name correct, shape
valid, but with max-age at zero it constructs no obligation — one character in one place dropped
the fully compliant count from 10/10 to 0/10, status code and body unchanged.
This does not undo the decision to reduce write sites to one; it says where the measurement must sit. A single layer ends the deviation and concentrates the value risk; catching both requires an audit that runs not next to the configuration but on the response itself.
The Encrypted-Connection Assumption
The first header in the set differs from the others: it is meaningful only over an encrypted connection, and the application cannot directly see that the connection is encrypted. TLS termination happens not in the application but in the reverse proxy in front of it; which layer it sits in and its cost were measured in The Traffic Layer course and are not repeated here. The only information left to the application is a header the proxy forwards.
AS7 (assumption): local connections are unencrypted; a request through the proxy counts as encrypted, one arriving directly as unencrypted. What is measured is how accurately the application guesses this. AS8 (assumption): the local socket in the third setting is the measurable counterpart of “the application is reachable from the network only through the proxy.”
// transport.mjs — the encrypted-connection assumption import http from "node:http"; import { tmpdir } from "node:os"; import { rmSync } from "node:fs"; import { SET } from "./hardening.mjs"; const HEADER = "x-forwarded-proto"; const local = (port) => ({ host: "127.0.0.1", port }); const listen = (s, address) => new Promise((c) => s.listen(address, () => c(s))); // app: redirects the unencrypted const app = (rule) => (q, y) => { const p = (q.headers[HEADER] ?? "").split(",").map((s) => s.trim()).filter(Boolean) .at(rule === "first" ? 0 : -1); if (p !== "https") return y.writeHead(308, { location: "https://loans.library" + q.url }).end(); y.writeHead(200, { "strict-transport-security": SET["strict-transport-security"].value }); y.end(JSON.stringify({ endpoint: q.url })); }; // proxy: terminates and forwards function proxy(target, shape) { const s = http.createServer((q, y) => { const incoming = q.headers[HEADER], chain = shape === "appending" && incoming ? `${incoming}, https` : "https"; const i = http.request({ ...target, path: q.url, headers: { ...q.headers, [HEADER]: chain } }, (c) => { y.writeHead(c.statusCode, c.headers); c.pipe(y); }); i.end(); }); return listen(s, local(0)); } const listenApp = (rule, address) => { if (typeof address === "string") rmSync(address, { force: true }); return listen(http.createServer(app(rule)), address); }; const sendRequest = (target, sent) => new Promise((c) => { const i = http.get({ ...target, path: "/loan/borrow", headers: sent ? { [HEADER]: sent } : {} }, (y) => { y.resume(); c(y); }); i.on("error", () => c({ statusCode: 0 })); }); // a TCP address known to be closed const closed = await listen(http.createServer(), local(0)); const closedPort = closed.address().port; closed.close(); const REQUESTS = ["proxy", "direct"].flatMap((y) => [null, "https", "http"].map((g) => [y, g])); const socket = tmpdir() + "/loans.sock"; const SETTINGS = [ ["appending proxy, first value", "first", "appending", null], ["appending proxy, last value", "last", "appending", null], ["overwriting proxy, socket only", "last", "overwriting", socket], ]; console.log(`${REQUESTS.length} requests: 3 through the proxy (actually: encrypted), 3 direct (actually: unencrypted)\n`); console.log(`${"setting".padEnd(34)}${"false accept".padStart(13)}${"false reject".padStart(14)}` + `${"transport header/6".padStart(20)}${"unreachable/3".padStart(15)}`); for (const [name, rule, shape, address] of SETTINGS) { const application = await listenApp(rule, address ?? local(0)); const v = await proxy(address ? { socketPath: address } : local(application.address().port), shape); let [falseAccept, falseReject, transportHeader, unreachable] = [0, 0, 0, 0]; for (const [path, sent] of REQUESTS) { const encrypted = path === "proxy"; const y = await sendRequest(local(encrypted ? v.address().port : address ? closedPort : application.address().port), sent); if (y.statusCode === 0) { unreachable++; continue; } if (y.headers["strict-transport-security"]) transportHeader++; if (!encrypted && y.statusCode === 200) falseAccept++; if (encrypted && y.statusCode === 308) falseReject++; } console.log(name.padEnd(34) + String(falseAccept).padStart(13) + String(falseReject).padStart(14) + String(transportHeader).padStart(20) + String(unreachable).padStart(15)); v.close(); application.close(); } // the cost of a false reject const application = await listenApp("first", local(0)); const v = await proxy(local(application.address().port), "appending"); const statuses = []; for (let i = 0; i < 5; i++) statuses.push((await sendRequest(local(v.address().port), "http")).statusCode); console.log(`\nin the "first value" setting, an encrypted request carrying a reversed header: 5 attempts -> ${statuses.join(", ")}`); console.log(`5xx count returned by the application: 0; no error in the log`); v.close(); application.close();
6 requests: 3 through the proxy (actually: encrypted), 3 direct (actually: unencrypted) setting false accept false reject transport header/6 unreachable/3 appending proxy, first value 1 1 3 0 appending proxy, last value 1 0 4 0 overwriting proxy, socket only 0 0 3 3 in the "first value" setting, an encrypted request carrying a reversed header: 5 attempts -> 308, 308, 308, 308, 308 5xx count returned by the application: 0; no error in the log
All three settings see the same six requests. The first setting reads the chain’s first value, which the client can write: an unencrypted impersonator is accepted (false accept), a genuinely encrypted request turned back (false reject). The second setting reads the last value, written by the layer closest to the application; the false reject drops to zero, but the false accept remains, since on a direct request the chain holds one client-written value — “last” reads the same as “first” there.
The third setting changes two things at once: the proxy overwrites what the client sent instead of forwarding it, and the application listens only on a local socket, not the network. The three direct requests cannot connect, both error shapes drop to zero, and the transport header lands on exactly the three responses that deserve it. The outcome is a path rule more than a header rule: trusting the forwarded header means trusting the layer writing it is the only entry point.
The cost of the false reject is silent too. In the first setting, an encrypted request with a reversed header returned 308 on five of five attempts; a client following the redirect reproduces the same request and gets the same response back. The application’s 5xx count is zero — no one searching logs for an error finds this loop. The same decision binds the previous section: the transport header is written only when the connection counts as encrypted, and in the first setting one of those three responses went to an unencrypted connection.
Summary
- Per-endpoint writing has 10 write sites and 58 record lines; two deviations turned up in 2 files. Migrating to a single layer touched 11 files, cutting write sites to 1 and new-endpoint lines to 0.
- Across three settings, 30 of 30 requests returned 200, 0 errors; hardening bytes were 2591 / 2660 / 2400, and the fewest-byte setting is the one whose set is complete on 0 of 10 endpoints — response size gives no order of correctness.
- A written-but-ineffective value is counted separately from a missing header: one wrong value in a single layer dropped the compliant-endpoint count from 10/10 to 0/10, status code and body unchanged — why the audit runs on the response, not the configuration.
- The forwarded protocol header’s first-value rule produced 1 false accept and 1 false reject; the last-value rule zeroed only the false reject, since on a direct request the client writes the chain’s only value.
- An overwriting proxy plus a local-socket-only listener zeroed both: 3 direct requests could not connect, and the header was written only on the 3 responses that deserved it. The false reject leaves no trace — an encrypted request with a reversed header returned 308 on 5 of 5 attempts, a 5xx count of 0.
Next Step
The transport side was hardened here: headers gathered into a single layer, their values auditable from the response, the encrypted-connection assumption tied to a single path. But every value used was written into the code as plain text. The path the proxy reaches the application by, the values the audit expects, and the keys the encrypted connection rests on all sit in the same place: the source files. The next lesson counts these values — how many remain in the code, how many services a change touches, and how many seconds the window lasts where the old and new value are both valid during rotation.
To keep your progress and take notes, Log in
My notes
Log in to take notes.