Tighten code comments + README (todo §0): denser, drop redundant prose; no behavior change
This commit is contained in:
11
src/app.ts
11
src/app.ts
@@ -8,8 +8,7 @@ import { serveStatic } from "./static.ts";
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export interface AppOptions {
|
||||
// Cache compiled templates (compile once vs. re-read+recompile per request).
|
||||
// Defaults to on in production, off in dev so source edits show up live.
|
||||
// Cache compiled templates: on in production, off in dev so edits show live.
|
||||
cache?: boolean;
|
||||
publicDir?: string;
|
||||
viewsDir?: string;
|
||||
@@ -35,8 +34,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// The single request shape handlers receive (§2/§4 router passes it on); routing
|
||||
// reads its parsed URL instead of building a throwaway one.
|
||||
// The request shape handlers receive (§2/§4 router passes it on); routing
|
||||
// reuses its parsed URL instead of building a throwaway.
|
||||
const { pathname } = buildContext(req, res).url;
|
||||
|
||||
if (pathname.startsWith("/public/")) {
|
||||
@@ -54,8 +53,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
console.error(err);
|
||||
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||
try {
|
||||
// Render first: if the error page itself fails, headers stay unsent and we
|
||||
// fall back to plain text below rather than emit a half-written response.
|
||||
// Render before writing: if the 500 page itself throws, headers stay unsent
|
||||
// and we fall back to plain text below instead of a half-written response.
|
||||
sendHtml(res, 500, await render("500", { title: "Server error" }));
|
||||
} catch (renderErr) {
|
||||
console.error(renderErr);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Config loaded once from the environment at boot (todo §0): Ory endpoints, the
|
||||
// cookie/CSRF secrets, the JWKS location, and the listen port. Fail-loud — a missing
|
||||
// production secret, a bad URL, or an out-of-range port throws here, before the server
|
||||
// starts, never at request time.
|
||||
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF
|
||||
// secrets, JWKS location, listen port. Fail-loud — a missing prod secret, a bad URL, or
|
||||
// an out-of-range port throws here at boot, never at request time.
|
||||
//
|
||||
// Clean-clone philosophy (README): every value has a working dev default so `docker
|
||||
// compose up` runs with zero config; in production only the secrets must be supplied
|
||||
// (the dev throwaways are refused), everything else still defaults to the Ory services.
|
||||
// Clean-clone (README): every value has a working dev default, so `docker compose up`
|
||||
// runs with zero config; in production the secrets must be supplied (dev throwaways
|
||||
// refused), everything else still defaults to the Ory services.
|
||||
|
||||
export interface Config {
|
||||
cookieSecret: string;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in). Built
|
||||
// once per request by `buildContext`: the router supplies matched path `params`,
|
||||
// the §4 JWT middleware supplies the `user` (null/[] until then). Handlers read the
|
||||
// request and write the response through it — the host's single handler argument.
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
|
||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||
|
||||
// The authenticated user, projected from the verified session JWT claims (§4):
|
||||
// The authenticated user, projected from verified session JWT claims (§4):
|
||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||
export interface User {
|
||||
email: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
|
||||
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session
|
||||
// JWT + CSRF token with these. Values round-trip via percent-encoding (serialize
|
||||
// encodes, parse decodes); JWT `-_.` chars are URI-unreserved, so JWTs stay readable.
|
||||
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
|
||||
// URI-unreserved, so JWTs stay readable.
|
||||
|
||||
export interface CookieOptions {
|
||||
domain?: string;
|
||||
@@ -22,7 +22,7 @@ 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
|
||||
if (!value.includes("%")) return value; // fast path: nothing to decode
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
@@ -30,10 +30,9 @@ function decode(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Parse a `Cookie` header into a name→value map. First occurrence of a name wins.
|
||||
// Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header
|
||||
// length is bounded upstream by Node's `maxHeaderSize` (~16 KB).
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = Object.create(null);
|
||||
if (!header) return out;
|
||||
@@ -50,10 +49,9 @@ export function parseCookies(header: string | undefined): Record<string, string>
|
||||
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.
|
||||
// Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of
|
||||
// chars that could inject extra attributes or split the header (CRLF). Cheap insurance
|
||||
// against Set-Cookie injection, even though these come from config.
|
||||
function assertAttrSafe(label: string, value: string): void {
|
||||
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
23
src/jwt.ts
23
src/jwt.ts
@@ -2,16 +2,13 @@ import { createPublicKey, verify } from "node:crypto";
|
||||
import type { JsonWebKey, KeyObject } from "node:crypto";
|
||||
|
||||
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0):
|
||||
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures the
|
||||
// Kratos tokenizer produces — all we need, no supply-chain surface (see AGENTS.md).
|
||||
//
|
||||
// Signature only. §4 builds the rest on top: claim checks (exp/iss/aud, clock skew),
|
||||
// JWKS-by-`kid` fetch/cache/rotation, and bounding `token` type/length at the boundary.
|
||||
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
|
||||
// the Kratos tokenizer produces (see AGENTS.md). Signature only — §4 adds claim checks
|
||||
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
|
||||
|
||||
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
|
||||
// Widen support by extending this map. Security invariant: never add an `HS*` (symmetric)
|
||||
// entry — this map is the allowlist, and one would let an attacker-supplied HMAC key verify.
|
||||
// `none` is absent for the same reason.
|
||||
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
|
||||
// is the allowlist, and a symmetric entry lets an attacker-supplied HMAC key verify.
|
||||
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
|
||||
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
|
||||
RS256: { hash: "RSA-SHA256", keyType: "rsa" },
|
||||
@@ -29,9 +26,9 @@ export interface DecodedJws {
|
||||
signature: Buffer;
|
||||
}
|
||||
|
||||
// Unpadded base64url alphabet — `Buffer.from(_, "base64url")` is lax (drops junk, tolerates
|
||||
// non-canonical padding), so reject non-canonical segments up front. §4 reads `kid` from the
|
||||
// still-unverified header, so this stops laundered bytes reaching key selection.
|
||||
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
|
||||
// bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still-
|
||||
// unverified header, so this stops laundered bytes reaching key selection.
|
||||
const base64url = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function decodeSegment(segment: string): unknown {
|
||||
@@ -68,8 +65,8 @@ export function decodeJws(token: string): DecodedJws {
|
||||
}
|
||||
|
||||
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
|
||||
// Signature only — the caller validates claims. The returned header is post-verification,
|
||||
// so §4 can trust its `alg`/`kid` when logging.
|
||||
// Signature only — caller validates claims. Returned header is post-verification, so §4
|
||||
// can trust its `alg`/`kid` when logging.
|
||||
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
|
||||
const decoded = decodeJws(token);
|
||||
const { header, signingInput, signature } = decoded;
|
||||
|
||||
@@ -22,9 +22,8 @@ export function contentTypeFor(filePath: string): string {
|
||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// Resolves a request path inside `dir`, or null if it would escape (traversal) or
|
||||
// carries a control char (NUL etc.) — rejecting those here makes the guard explicit
|
||||
// rather than relying on a downstream `stat` to throw.
|
||||
// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
|
||||
// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
|
||||
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
||||
if (/[\x00-\x1f]/.test(requestedPath)) return null;
|
||||
const filePath = join(dir, requestedPath);
|
||||
@@ -52,8 +51,8 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
|
||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||
if (head) return void res.end(); // headers only — skip opening the file
|
||||
// Headers are already sent, so a mid-stream read error can't become an HTTP error —
|
||||
// log it and destroy the response to signal a truncated body, not a hung socket.
|
||||
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
||||
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||
createReadStream(filePath)
|
||||
.on("error", (err) => {
|
||||
console.error(err);
|
||||
|
||||
Reference in New Issue
Block a user