Add Cookie header parse + Set-Cookie build helpers (todo §0); destroy response on static mid-stream error

This commit is contained in:
2026-06-14 18:41:31 +02:00
parent 5020be592a
commit b4c149db27
4 changed files with 215 additions and 2 deletions

112
src/cookie.test.ts Normal file
View File

@@ -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<string, string> => ({ ...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);
});

97
src/cookie.ts Normal file
View File

@@ -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<string, string> {
const out: Record<string, string> = 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("; ");
}

View File

@@ -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");
}