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
↓ kepr-80ceee69.tar.gzatlas at this save
files changed5 files
package.json+1 −1scripts/budget.ts+63 −0src/main.ts+13 −0src/server/app.ts+32 −0tests/server.test.ts+47 −0package.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": {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);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+}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+}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+});