From b4c149db278e0ecba7e837f5eb5c130c0cbec069 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 14 Jun 2026 18:41:31 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Cookie=20header=20parse=20+=20Set-Cookie?= =?UTF-8?q?=20build=20helpers=20(todo=20=C2=A70);=20destroy=20response=20o?= =?UTF-8?q?n=20static=20mid-stream=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cookie.test.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/cookie.ts | 97 +++++++++++++++++++++++++++++++++++++++ src/static.ts | 6 ++- todo.md | 2 +- 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/cookie.test.ts create mode 100644 src/cookie.ts diff --git a/src/cookie.test.ts b/src/cookie.test.ts new file mode 100644 index 0000000..5b612c3 --- /dev/null +++ b/src/cookie.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseCookies, serializeCookie } from "./cookie.ts"; + +// parseCookies returns a null-prototype map; spread into a plain object to deep-equal. +const flat = (header: string | undefined): Record => ({ ...parseCookies(header) }); + +test("parseCookies returns an empty object for an absent or empty header", () => { + assert.deepEqual(flat(undefined), {}); + assert.deepEqual(flat(""), {}); +}); + +test("parseCookies splits pairs and trims surrounding whitespace", () => { + assert.deepEqual(flat("a=1; b=2"), { a: "1", b: "2" }); + assert.deepEqual(flat(" a = 1 ;b= 2"), { a: "1", b: "2" }); +}); + +test("parseCookies keeps `=` inside the value (base64/JWT-like tokens)", () => { + assert.deepEqual(flat("t=ey.Jh=="), { t: "ey.Jh==" }); +}); + +test("parseCookies decodes percent-encoded values, raw on malformed", () => { + assert.equal(parseCookies("a=one%20two").a, "one two"); + assert.equal(parseCookies("a=%E0%A4%A").a, "%E0%A4%A"); // invalid escape → untouched, no throw +}); + +test("parseCookies strips one layer of surrounding double-quotes", () => { + assert.equal(parseCookies('a="quoted"').a, "quoted"); +}); + +test("parseCookies skips pairs without a name or `=`", () => { + assert.deepEqual(flat("novalue; =orphan; a=1"), { a: "1" }); +}); + +test("parseCookies keeps the first occurrence of a duplicate name", () => { + assert.equal(parseCookies("a=first; a=second").a, "first"); +}); + +test("parseCookies is not vulnerable to prototype pollution", () => { + const parsed = parseCookies("__proto__=polluted; a=1"); + assert.equal(Object.getPrototypeOf(parsed), null); // null-prototype map + assert.equal(parsed["__proto__"], "polluted"); // stored as a plain own key, not the prototype + assert.equal(parsed.a, "1"); + assert.equal(Object.getPrototypeOf({}), Object.prototype); // global prototype untouched +}); + +test("serializeCookie emits a bare name=value, encoding the value", () => { + assert.equal(serializeCookie("session", "abc"), "session=abc"); + assert.equal(serializeCookie("session", "a b&c"), "session=a%20b%26c"); +}); + +test("serializeCookie leaves JWT characters (-_.) readable", () => { + assert.equal(serializeCookie("session", "ab-_.cd"), "session=ab-_.cd"); +}); + +test("serializeCookie appends the secure-by-default attribute flags", () => { + const out = serializeCookie("session", "x", { httpOnly: true, path: "/", sameSite: "Lax", secure: true }); + assert.equal(out, "session=x; Path=/; HttpOnly; SameSite=Lax; Secure"); +}); + +test("serializeCookie writes Max-Age and rejects a non-integer", () => { + assert.match(serializeCookie("a", "1", { maxAge: 600 }), /; Max-Age=600(;|$)/); + assert.match(serializeCookie("a", "1", { maxAge: 0 }), /; Max-Age=0(;|$)/); + assert.throws(() => serializeCookie("a", "1", { maxAge: 1.5 }), /integer/); +}); + +test("serializeCookie writes Expires from a Date and rejects an invalid one", () => { + assert.match(serializeCookie("a", "1", { expires: new Date(0) }), /; Expires=Thu, 01 Jan 1970 00:00:00 GMT/); + assert.throws(() => serializeCookie("a", "1", { expires: new Date("nope") }), /Expires/); +}); + +test("serializeCookie rejects an Expires year outside the 4-digit RFC range", () => { + // toUTCString() of a year > 9999 yields a 6-digit year browsers may reject — fail loud instead. + assert.throws(() => serializeCookie("a", "1", { expires: new Date(8640000000000000) }), /Expires/); +}); + +test("serializeCookie writes Domain and Path", () => { + const out = serializeCookie("a", "1", { domain: "example.com", path: "/admin" }); + assert.match(out, /; Domain=example\.com/); + assert.match(out, /; Path=\/admin/); +}); + +test("serializeCookie rejects an empty Domain or Path (misconfigured deploy)", () => { + assert.throws(() => serializeCookie("a", "1", { domain: "" }), /domain/); + assert.throws(() => serializeCookie("a", "1", { path: "" }), /path/); +}); + +test("serializeCookie allows a non-positive Max-Age (expire immediately, by design)", () => { + assert.match(serializeCookie("a", "1", { maxAge: -1 }), /; Max-Age=-1(;|$)/); +}); + +test("serializeCookie rejects SameSite=None without Secure (browsers would drop it)", () => { + assert.throws(() => serializeCookie("a", "1", { sameSite: "None" }), /Secure/); + assert.doesNotThrow(() => serializeCookie("a", "1", { sameSite: "None", secure: true })); +}); + +test("serializeCookie rejects an invalid cookie name", () => { + assert.throws(() => serializeCookie("bad name", "1"), /name/); + assert.throws(() => serializeCookie("a;b", "1"), /name/); +}); + +test("serializeCookie rejects attribute values that could inject extra attributes", () => { + assert.throws(() => serializeCookie("a", "1", { path: "/x; Domain=evil.com" }), /path/); + assert.throws(() => serializeCookie("a", "1", { domain: "evil\r\nSet-Cookie: x=y" }), /domain/); +}); + +test("serializeCookie and parseCookies round-trip an arbitrary value", () => { + const value = "header.payload.sig with spaces & symbols="; + const setCookie = serializeCookie("session", value, { httpOnly: true }); + const cookieHeader = setCookie.split("; ")[0]; // browsers send only name=value + assert.equal(parseCookies(cookieHeader).session, value); +}); diff --git a/src/cookie.ts b/src/cookie.ts new file mode 100644 index 0000000..2eb955f --- /dev/null +++ b/src/cookie.ts @@ -0,0 +1,97 @@ +// Cookie helpers — parse the request `Cookie` header and build `Set-Cookie` +// response headers with secure-by-default attributes. Stdlib only (no `cookie` dep). +// §4 auth uses these to store/clear the session JWT cookie and the CSRF token. +// +// Values round-trip via percent-encoding: `serializeCookie` encodes, `parseCookies` +// decodes. JWTs survive unescaped (their `-_.` base64url chars are URI-unreserved), +// so the header stays human-readable. + +export interface CookieOptions { + domain?: string; + expires?: Date; + httpOnly?: boolean; + maxAge?: number; // seconds; 0 / negative expire the cookie immediately + path?: string; + sameSite?: "Lax" | "None" | "Strict"; + secure?: boolean; +} + +// RFC 6265 cookie-name token: no control chars, whitespace, or separators. +const cookieName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +// Cookie Expires must be a 4-digit-year HTTP-date (RFC 1123); a Date outside this +// range makes toUTCString() emit a 6-digit/negative year browsers may reject. +const minExpires = Date.UTC(1601, 0, 1); +const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999); + +function decode(value: string): string { + if (!value.includes("%")) return value; // optimization only: an unencoded value has no escapes to decode + try { + return decodeURIComponent(value); + } catch { + return value; // malformed input is untrusted — keep raw rather than throw + } +} + +// Parse a request `Cookie` header into a name→value map. First occurrence of a +// name wins (a later duplicate can't shadow it). The result is a null-prototype +// object, so an attacker-supplied `__proto__`/`constructor` key can't pollute. +// Input length is bounded upstream by Node's HTTP `maxHeaderSize` (~16 KB default). +export function parseCookies(header: string | undefined): Record { + const out: Record = Object.create(null); + if (!header) return out; + + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq < 0) continue; + const name = pair.slice(0, eq).trim(); + if (!name || name in out) continue; + let value = pair.slice(eq + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + out[name] = decode(value); + } + return out; +} + +// Validate a Domain/Path attribute: non-empty (an empty one emits a junk `Path=` +// browsers ignore — fail loud on a misconfig), and free of chars that could inject +// extra attributes or split the response header (CRLF). These come from config, but +// validating is cheap insurance against Set-Cookie injection. +function assertAttrSafe(label: string, value: string): void { + if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`); +} + +// Build a `Set-Cookie` header value. Throws on inputs that would produce a +// malformed or injectable header. +export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string { + if (!cookieName.test(name)) throw new Error(`invalid cookie name: ${JSON.stringify(name)}`); + + const parts = [`${name}=${encodeURIComponent(value)}`]; + + if (options.maxAge !== undefined) { + if (!Number.isInteger(options.maxAge)) throw new Error("cookie maxAge must be an integer number of seconds"); + parts.push(`Max-Age=${options.maxAge}`); + } + if (options.domain !== undefined) { + assertAttrSafe("domain", options.domain); + parts.push(`Domain=${options.domain}`); + } + if (options.path !== undefined) { + assertAttrSafe("path", options.path); + parts.push(`Path=${options.path}`); + } + if (options.expires !== undefined) { + const t = options.expires.getTime(); + if (Number.isNaN(t)) throw new Error("cookie Expires is an invalid Date"); + if (t < minExpires || t > maxExpires) throw new Error("cookie Expires year is out of the 4-digit RFC range"); + parts.push(`Expires=${options.expires.toUTCString()}`); + } + if (options.httpOnly) parts.push("HttpOnly"); + if (options.sameSite !== undefined) { + if (options.sameSite === "None" && !options.secure) throw new Error("SameSite=None requires Secure"); + parts.push(`SameSite=${options.sameSite}`); + } + if (options.secure) parts.push("Secure"); + + return parts.join("; "); +} diff --git a/src/static.ts b/src/static.ts index 1915473..1281220 100644 --- a/src/static.ts +++ b/src/static.ts @@ -48,7 +48,11 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve const info = await stat(filePath); if (!info.isFile()) return plain(res, 404, "Not Found"); res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) }); - createReadStream(filePath).pipe(res); + // Headers are already sent, so a mid-stream read error can't become an HTTP error — + // destroy the response to signal a truncated body instead of leaving the socket open. + createReadStream(filePath) + .on("error", () => res.destroy()) + .pipe(res); } catch { plain(res, 404, "Not Found"); } diff --git a/todo.md b/todo.md index 517c0ad..4a7f7ac 100644 --- a/todo.md +++ b/todo.md @@ -13,7 +13,7 @@ everything via Docker. ## 0. Housekeeping / primitives - [x] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding. → `node:crypto` (no new dep); `src/jwt.ts` verifies JWS signatures. -- [ ] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). +- [x] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). → `src/cookie.ts` (`parseCookies`/`serializeCookie`); stdlib-only, injection/pollution-safe. - [ ] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`. - [ ] Error templates: add 403 + 500 (404 exists). - [ ] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports.