Add RequestContext primitive (todo §0); harden static serving (HEAD, control-char, stream-error logging)
This commit is contained in:
@@ -32,8 +32,26 @@ test("returns 404 for unknown routes", async () => {
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal, allows nested files", () => {
|
||||
test("blocks encoded path traversal out of /public/ with 403", async () => {
|
||||
const res = await fetch(base + "/public/..%2f..%2fapp.ts");
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
test("rejects a control char (NUL) in a static path with 403", async () => {
|
||||
const res = await fetch(base + "/public/%00");
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
test("HEAD on a static file sends headers but no body", async () => {
|
||||
const res = await fetch(base + "/public/css/style.css", { method: "HEAD" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(Number(res.headers.get("content-length")) > 0);
|
||||
assert.equal((await res.text()).length, 0);
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "css/style.css"), "/srv/public/css/style.css");
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (pathname.startsWith("/public/")) {
|
||||
await serveStatic(publicDir, pathname.slice("/public/".length), res);
|
||||
await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
51
src/context.test.ts
Normal file
51
src/context.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
|
||||
// A req/res pair without a live server — enough to build and inspect a context.
|
||||
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
if (url !== undefined) req.url = url;
|
||||
req.method = "GET";
|
||||
return { req, res: new ServerResponse(req) };
|
||||
}
|
||||
|
||||
test("buildContext parses the URL and defaults to an anonymous user", () => {
|
||||
const { req, res } = reqRes("/users?q=ann");
|
||||
const ctx = buildContext(req, res);
|
||||
assert.equal(ctx.req, req);
|
||||
assert.equal(ctx.res, res);
|
||||
assert.equal(ctx.url.pathname, "/users");
|
||||
assert.equal(ctx.user, null);
|
||||
assert.deepEqual(ctx.roles, []);
|
||||
assert.deepEqual(ctx.params, {});
|
||||
});
|
||||
|
||||
test("buildContext exposes query as the URL's search params", () => {
|
||||
const { req, res } = reqRes("/users?q=ann&page=2");
|
||||
const ctx = buildContext(req, res);
|
||||
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
|
||||
assert.equal(ctx.query.get("q"), "ann");
|
||||
assert.equal(ctx.query.get("page"), "2");
|
||||
});
|
||||
|
||||
test("buildContext threads path params supplied by the router", () => {
|
||||
const { req, res } = reqRes("/users/42");
|
||||
const ctx = buildContext(req, res, { params: { id: "42" } });
|
||||
assert.equal(ctx.params.id, "42");
|
||||
});
|
||||
|
||||
test("buildContext threads the user and derives roles from it", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { user });
|
||||
assert.equal(ctx.user, user);
|
||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
||||
});
|
||||
|
||||
test("buildContext defaults a missing request URL to /", () => {
|
||||
const { req, res } = reqRes();
|
||||
assert.equal(buildContext(req, res).url.pathname, "/");
|
||||
});
|
||||
47
src/context.ts
Normal file
47
src/context.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 authenticated user, projected from the verified session JWT claims (§4):
|
||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||
export interface User {
|
||||
email: string;
|
||||
id: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
url: URL;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export interface BuildContextOptions {
|
||||
params?: Record<string, string>;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export function buildContext(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
options: BuildContextOptions = {},
|
||||
): RequestContext {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const user = options.user ?? null;
|
||||
return {
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
req,
|
||||
res,
|
||||
roles: user?.roles ?? [],
|
||||
url,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
export interface CookieOptions {
|
||||
domain?: string;
|
||||
|
||||
@@ -71,6 +71,11 @@ test("rejects when the JWK pins a different alg", () => {
|
||||
assert.throws(() => verifyJws(token, { ...rsaJwk, alg: "RS512" }), /alg mismatch/);
|
||||
});
|
||||
|
||||
test("rejects a symmetric JWK (kty:oct) for an asymmetric alg — second defense after the allowlist", () => {
|
||||
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
|
||||
assert.throws(() => verifyJws(token, { k: b64url("secret"), kty: "oct" }), /invalid JWK/);
|
||||
});
|
||||
|
||||
test("rejects a token without three segments", () => {
|
||||
assert.throws(() => verifyJws("only.two", rsaJwk), /expected 3 segments/);
|
||||
});
|
||||
|
||||
13
src/jwt.ts
13
src/jwt.ts
@@ -1,15 +1,12 @@
|
||||
import { createPublicKey, verify } from "node:crypto";
|
||||
import type { JsonWebKey, KeyObject } from "node:crypto";
|
||||
|
||||
// JWT signature verification with the Node standard library — no `jose`/JWT package.
|
||||
// Decision (todo §0): `node:crypto` imports a JWK directly (`createPublicKey({format:"jwk"})`)
|
||||
// and verifies the RS*/ES* signatures the Kratos session tokenizer produces — everything
|
||||
// we need. A dependency would add supply-chain surface for capability we already have; see
|
||||
// AGENTS.md (few dependencies, prefer stdlib).
|
||||
// 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).
|
||||
//
|
||||
// Scope is signature verification only. The §4 auth layer builds the rest on top of this:
|
||||
// claim checks (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and — at its
|
||||
// network boundary — guarding `token` is a string and bounding its length before calling in.
|
||||
// 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.
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -22,8 +22,11 @@ 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 (path traversal).
|
||||
// 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.
|
||||
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
||||
if (/[\x00-\x1f]/.test(requestedPath)) return null;
|
||||
const filePath = join(dir, requestedPath);
|
||||
const rel = relative(dir, filePath);
|
||||
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
|
||||
@@ -33,7 +36,7 @@ function plain(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||
}
|
||||
|
||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse): Promise<void> {
|
||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false): Promise<void> {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(requestedPath);
|
||||
@@ -48,10 +51,14 @@ 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) });
|
||||
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 —
|
||||
// destroy the response to signal a truncated body instead of leaving the socket open.
|
||||
// log it and destroy the response to signal a truncated body, not a hung socket.
|
||||
createReadStream(filePath)
|
||||
.on("error", () => res.destroy())
|
||||
.on("error", (err) => {
|
||||
console.error(err);
|
||||
res.destroy();
|
||||
})
|
||||
.pipe(res);
|
||||
} catch {
|
||||
plain(res, 404, "Not Found");
|
||||
|
||||
Reference in New Issue
Block a user