80ceee69d14a

add security headers, the 64 KB body cap, the budget check and the route-list test (v3.0.3)

time
2026-09-26 16:10 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
62097d82837d
5 files package.json+1 −1scripts/budget.ts+63 −0src/main.ts+13 −0src/server/app.ts+32 −0tests/server.test.ts+47 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.2",3+	"version": "3.0.3",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

scripts/budget.ts+63 −0
@@ -0,0 +1,63 @@1+// §26: fails when any area passes its budget or the TypeScript total passes the 10,000-line ceiling.2+const CEILING = 10_000;3+4+// Prefixes are matched in order, so the specific files come before the directories.5+const TS_AREAS: [string, string[], number][] = [6+	["bucket", ["src/bucket/"], 900],7+	["mirror", ["src/mirror/"], 1100],8+	["describe", ["src/describe/"], 500],9+	["pages", ["src/pages/"], 2600],10+	["releases", ["src/releases/"], 900],11+	["tarball", ["src/tarball/"], 300],12+	["issues", ["src/issues/"], 600],13+	["admin", ["src/admin/"], 1100],14+	["import-kepr2", ["scripts/import-kepr2.ts"], 100],15+	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 700],16+];17+const ASSET_AREAS: [string, string, number][] = [18+	["public/js", "public/js/**/*.js", 1200],19+	["public/css", "public/css/**/*.css", 2000],20+];21+22+async function lineCount(path: string): Promise<number> {23+	const text = await Bun.file(path).text();24+	return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);25+}26+27+async function countFiles(pattern: string): Promise<Map<string, number>> {28+	const counts = new Map<string, number>();29+	for await (const path of new Bun.Glob(pattern).scan(".")) counts.set(path, await lineCount(path));30+	return counts;31+}32+33+const tsFiles = await countFiles("{src,scripts}/**/*.ts");34+const totals = new Map(TS_AREAS.map(([name]) => [name, 0]));35+const unassigned: string[] = [];36+for (const [path, lines] of tsFiles) {37+	if (path.endsWith(".test.ts")) continue;38+	const area = TS_AREAS.find(([, prefixes]) => prefixes.some((prefix) => path.startsWith(prefix)));39+	if (!area) unassigned.push(path);40+	else totals.set(area[0], (totals.get(area[0]) ?? 0) + lines);41+}42+43+const rows: [string, number, number][] = TS_AREAS.map(([name, , budget]) => [44+	name,45+	totals.get(name) ?? 0,46+	budget,47+]);48+const total = rows.reduce((sum, [, lines]) => sum + lines, 0);49+for (const [name, pattern, budget] of ASSET_AREAS) {50+	const files = await countFiles(pattern);51+	rows.push([name, [...files.values()].reduce((a, b) => a + b, 0), budget]);52+}53+54+let failed = false;55+for (const [name, lines, budget] of rows) {56+	const over = lines > budget;57+	failed ||= over;58+	console.log(`${name.padEnd(14)} ${String(lines).padStart(6)} / ${budget}${over ? "  OVER" : ""}`);59+}60+console.log(`${"typescript".padEnd(14)} ${String(total).padStart(6)} / ${CEILING}`);61+if (total > CEILING) failed = true;62+for (const path of unassigned) console.error(`no budget area for ${path}`);63+if (failed || unassigned.length > 0) process.exit(1);

view file

src/main.ts+13 −0
@@ -0,0 +1,13 @@1+import { createApp } from "./server/app.ts";2+import { ConfigError, loadConfig } from "./server/config.ts";3+4+try {5+	const config = await loadConfig("kepr.toml");6+	// Bun.serve reads the port from $PORT (default 3000).7+	const server = Bun.serve({ fetch: createApp(config).fetch });8+	console.log(`kepr listening on ${server.url}`);9+} catch (error) {10+	if (!(error instanceof ConfigError)) throw error;11+	console.error(`kepr: ${error.message}`);12+	process.exit(1);13+}

view file

src/server/app.ts+32 −0
@@ -0,0 +1,32 @@1+import { Hono } from "hono";2+import { bodyLimit } from "hono/body-limit";3+import { secureHeaders } from "hono/secure-headers";4+import type { Config } from "./config.ts";5+6+const MAX_BODY_BYTES = 64 * 1024;7+8+/** The Hono app with §25's headers and body cap; routes join it phase by phase. */9+export function createApp(config: Config): Hono {10+	const app = new Hono();11+	// §25 allows images from the presigned host, which is the bucket endpoint's origin.12+	const imgSrc =13+		config.bucket.kind === "s3" ? ["'self'", new URL(config.bucket.endpoint).origin] : ["'self'"];14+	app.use(15+		secureHeaders({16+			contentSecurityPolicy: {17+				defaultSrc: ["'self'"],18+				scriptSrc: ["'self'"],19+				imgSrc,20+				objectSrc: ["'none'"],21+				baseUri: ["'none'"],22+				formAction: ["'self'"],23+				frameAncestors: ["'none'"],24+			},25+			xFrameOptions: "DENY",26+		}),27+	);28+	app.use(29+		bodyLimit({ maxSize: MAX_BODY_BYTES, onError: (c) => c.text("Request body too large", 413) }),30+	);31+	return app;32+}

view file

tests/server.test.ts+47 −0
@@ -0,0 +1,47 @@1+import { expect, test } from "bun:test";2+import { createApp } from "../src/server/app.ts";3+import type { Config } from "../src/server/config.ts";4+5+const config: Config = {6+	domain: "kepr.example",7+	dataDir: "data",8+	adminPasswordHash: "$argon2id$x",9+	bucket: {10+		kind: "s3",11+		endpoint: "https://s3.fr-par.scw.cloud",12+		name: "koh",13+		region: "fr-par",14+		accessKeyId: "read",15+		secretAccessKey: "only",16+	},17+};18+19+test("every route the app registers is listed in docs/routes.txt", async () => {20+	const listed = new Set(21+		(await Bun.file("docs/routes.txt").text())22+			.split("\n")23+			.filter((line) => line.trim() !== "" && !line.startsWith("#")),24+	);25+	const registered = createApp(config)26+		.routes.filter((route) => !(route.method === "ALL" && route.path === "/*"))27+		.map((route) => `${route.method} ${route.path}`);28+	expect(registered.filter((route) => !listed.has(route))).toEqual([]);29+});30+31+test("responses carry the §25 content security policy", async () => {32+	const response = await createApp(config).request("/");33+	const csp = response.headers.get("content-security-policy") ?? "";34+	expect(csp).toContain("script-src 'self'");35+	expect(csp).not.toContain("unsafe-inline");36+	expect(csp).toContain("img-src 'self' https://s3.fr-par.scw.cloud");37+	expect(csp).toContain("frame-ancestors 'none'");38+	expect(response.headers.get("x-content-type-options")).toBe("nosniff");39+});40+41+test("request bodies over 64 KB are refused", async () => {42+	const app = createApp(config);43+	app.post("/echo", async (c) => c.text(String((await c.req.text()).length)));44+	const post = (bytes: number) => app.request("/echo", { method: "POST", body: "x".repeat(bytes) });45+	expect((await post(64 * 1024)).status).toBe(200);46+	expect((await post(64 * 1024 + 1)).status).toBe(413);47+});

view file