8722a5c608af

gate Kepr 2's redirects behind the visibility check, treat a page past the last as page 1, refuse a non-hostname domain at startup, reject a flag or clear event with a path, and validate static paths before touching the disk (v3.0.45)

time
2026-09-26 21:23 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
71ae032e7c17
12 files package.json+1 −1src/bucket/repo.ts+2 −2src/pages/kepr2.ts+13 −8src/pages/repo.ts+3 −1src/server/app.ts+2 −1src/server/config.ts+7 −1tests/bucket.test.ts+2 −0tests/config.test.ts+19 −0tests/console.test.ts+5 −0tests/front.test.ts+3 −0tests/kepr2.test.ts+28 −1tests/pages.test.ts+9 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.44",3+	"version": "3.0.45",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/repo.ts+2 −2
@@ -304,10 +304,10 @@304304 		SAVE_ID.test(r.id) &&305305 		isOptionalSaveId(r.save_id) &&306306 		(r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&307-		// A flag or clear is about one save; a strip names a path, and one save or (with no ID) all.307+		// A flag or clear is about one save and no path; a strip names a path, and one save or (with no ID) all.308308 		(r.kind === "strip"309309 			? r.path !== ""310-			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") &&310+			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "") &&311311 		typeof r.reason === "string" &&312312 		typeof r.machine === "string" &&313313 		isCount(r.created_at);

view file

src/pages/kepr2.ts+13 −8
@@ -1,5 +1,6 @@1-import type { Hono } from "hono";1+import type { Context, Hono } from "hono";22 import { isSafePath, isVersion } from "../bucket/repo.ts";3+import { publicRepo } from "../server/db.ts";34 import { notFound, type PageContext, pathUrl } from "./layout.ts";45 56 // §28: Kepr 2's addresses. Every move is a 301: none of them will ever point anywhere else.@@ -27,26 +28,30 @@2728 2829 /** Registered ahead of the repo page, so `/source` and the gone paths are never taken for repos. */2930 export function registerKepr2Routes(app: Hono, ctx: PageContext): void {31+	// §20: a move of a private or unknown repo would reveal it, so it 404s like every repo route.32+	const moved = (c: Context, rest: string) => {33+		const repo = c.req.param("repo") ?? "";34+		if (!publicRepo(ctx.kepr, repo)) return notFound(c, ctx);35+		return c.redirect(`/${encodeURIComponent(repo)}${rest}`, 301);36+	};3037 	for (const path of GONE) app.all(path, (c) => c.text("Gone", 410));3138 	app.get("/source", (c) => c.redirect("/kepr", 301));3239 	app.get("/:repo", async (c, next) => {3340 		const panel = PANELS.get(c.req.query("panel") ?? "");3441 		if (panel === undefined) return next();35-		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}${panel}`, 301);42+		return moved(c, panel);3643 	});3744 	app.get("/:repo/releases/:version", async (c, next) => {3845 		const version = c.req.param("version");3946 		if (RELEASE_ROUTES.has(version)) return next();4047 		if (!isVersion(version)) return notFound(c, ctx);41-		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}/releases#${version}`, 301);48+		return moved(c, `/releases#${version}`);4249 	});4350 	app.get("/:repo/file", (c) => {4451 		const path = c.req.query("path") ?? "";4552 		if (!isSafePath(path)) return notFound(c, ctx);46-		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}/files/${pathUrl(path)}`, 301);53+		return moved(c, `/files/${pathUrl(path)}`);4754 	});48-	app.get("/:repo/bundle", (c) => c.redirect(headTarball(c.req.param("repo")), 301));49-	app.get("/:repo/bundle.blake3", (c) => c.redirect(headTarball(c.req.param("repo")), 301));55+	app.get("/:repo/bundle", (c) => moved(c, "/archive/HEAD.tar.gz"));56+	app.get("/:repo/bundle.blake3", (c) => moved(c, "/archive/HEAD.tar.gz"));5057 }51-52-const headTarball = (repo: string) => `/${encodeURIComponent(repo)}/archive/HEAD.tar.gz`;

view file

src/pages/repo.ts+3 −1
@@ -100,10 +100,12 @@100100 </header>`;101101 }102102 103-function sections(repo: PublicRepo, pageNumber: number, at: string | null): Html {103+function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {104104 	const name = repo.settings.name;105105 	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);106106 	const saves = timeline(repo.mirror);107+	// Past the last page, even at an unbindable number like Infinity, is page 1.108+	const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;107109 	const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);108110 	const count = plural(saves.length, "save");109111 	const older = pageNumber * PAGE_SIZE < saves.length;

view file

src/server/app.ts+2 −1
@@ -60,10 +60,11 @@6060 	const assetsDir = join(config.dataDir, "assets");6161 	app.get("/static/:path{.+}", async (c) => {6262 		const path = c.req.param("path");63+		if (!isSafePath(path)) return c.notFound();6364 		const file = Bun.file(6465 			path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path),6566 		);66-		if (!isSafePath(path) || !(await file.exists())) return c.notFound();67+		if (!(await file.exists())) return c.notFound();6768 		c.header("Cache-Control", "public, max-age=3600");6869 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });6970 	});

view file

src/server/config.ts+7 −1
@@ -19,6 +19,8 @@1919 2020 export class ConfigError extends Error {}2121 22+const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/i;23+2224 export function parseConfig(text: string): Config {2325 	let raw: unknown;2426 	try {@@ -32,8 +34,12 @@3234 	if (!adminPasswordHash.startsWith("$argon2id$")) {3335 		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");3436 	}37+	const domain = requireString(top, "domain");38+	// Pages, feeds and install.sh put it in URLs and a shell script as is.39+	if (!HOSTNAME.test(domain))40+		throw new ConfigError("domain must be a bare hostname, with an optional :port");3541 	return {36-		domain: requireString(top, "domain"),42+		domain,3743 		dataDir: requireString(top, "data_dir"),3844 		adminPasswordHash,3945 		bucket: parseBucket(bucket),

view file

tests/bucket.test.ts+2 −0
@@ -220,6 +220,8 @@220220 			{ save_id: "nope" },221221 			{ kind: "flag", save_id: "" },222222 			{ kind: "clear", save_id: "" },223+			{ kind: "flag", path: "secret.txt" },224+			{ kind: "clear", path: "secret.txt" },223225 			{ kind: "strip", path: "" },224226 			{ kind: "strip", save_id: "", path: "" },225227 		]) {

view file

tests/config.test.ts+19 −0
@@ -51,3 +51,22 @@5151 		"must be a URL",5252 	);5353 });54+55+test("refuses a domain that is not a bare hostname, since install.sh embeds it as is", () => {56+	for (const domain of ["kepr.example:8443", "localhost", "a-b.c1.example"]) {57+		expect(parseConfig(s3.replace("kepr.example", domain)).domain).toBe(domain);58+	}59+	for (const domain of [60+		"kepr.example/x",61+		"https://kepr.example",62+		"kepr.example$(id)",63+		"kepr.example; rm x",64+		"kepr.example\\nx",65+		"-kepr.example",66+		"kepr..example",67+		"kepr.example:",68+		"[email protected]",69+	]) {70+		expect(() => parseConfig(s3.replace("kepr.example", domain))).toThrow("bare hostname");71+	}72+});

view file

tests/console.test.ts+5 −0
@@ -39,6 +39,11 @@3939 		"/app/issues",4040 		"/app/issues/new",4141 		"/app/issues/1",42+		"/app?panel=issues",43+		"/app/file?path=README.md",44+		"/app/bundle",45+		"/app/bundle.blake3",46+		"/app/releases/1.0.0",4247 	];4348 	const statuses = async () => {4449 		const out: string[] = [];

view file

tests/front.test.ts+3 −0
@@ -207,6 +207,9 @@207207 				"/static/assets/%2e%2e%2fkepr.db",208208 				"/static/assets/..%5Ckepr.db",209209 				"/static/assets%2F..%2Fkepr.db",210+				"/static/%00",211+				"/static/assets/%00",212+				"/static/css/%01kepr.css",210213 			]) {211214 				expect((await app.request(path)).status).toBe(404);212215 			}

view file

tests/kepr2.test.ts+28 −1
@@ -15,9 +15,21 @@1515 		adminPasswordHash: "$argon2id$x",1616 		bucket: { kind: "folder" as const, path: dataDir },1717 	};18-	return createApp(config, openKeprDb(dataDir));18+	const kepr = openKeprDb(dataDir);19+	// The redirects read only the visibility flag, so no mirror is needed.20+	kepr.run("INSERT INTO repos (name, public) VALUES ('demo', 1), ('hidden', 0)");21+	return createApp(config, kepr);1922 }2023 24+const MOVED = [25+	"?panel=issues",26+	"?panel=trust",27+	"/releases/2.1.0",28+	"/file?path=src/main.ts",29+	"/bundle",30+	"/bundle.blake3",31+];32+2133 test("§28: Kepr 2's moved addresses answer 301 with the new one", async () => {2234 	const kepr = await app();2335 	const moves: [string, string][] = [@@ -38,6 +50,16 @@3850 	}3951 });4052 53+test("§20: a private or unknown repo's old addresses 404 instead of redirecting", async () => {54+	const kepr = await app();55+	for (const repo of ["hidden", "nowhere"]) {56+		for (const rest of MOVED) {57+			const path = `/${repo}${rest}`;58+			expect([path, (await kepr.request(path)).status]).toEqual([path, 404]);59+		}60+	}61+});62+4163 test("§28: Kepr 2's retired addresses answer 410 Gone, for any method", async () => {4264 	const kepr = await app();4365 	const gone = [@@ -74,6 +96,11 @@7496 		"/demo/releases/history.json",7597 		"/demo",7698 		"/demo?panel=nope",99+	]) {100+		const status = (await kepr.request(path)).status;101+		expect([path, status === 301 || status >= 500]).toEqual([path, false]);102+	}103+	for (const path of [77104 		"/demo/file",78105 		"/demo/file?path=../etc/passwd",79106 		"/demo/file?path=a//b",

view file

tests/pages.test.ts+9 −0
@@ -106,6 +106,15 @@106106 		expect(history).toEqual([head(), second(), first()]);107107 	});108108 109+	test("a page past the last, or too large to bind, is page 1", async () => {110+		const history = (page: string) => [...page.matchAll(/class="history-id">([0-9a-f]+)</g)];111+		for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {112+			const response = await get(`/demo${query}`);113+			expect([query, response.status]).toEqual([query, 200]);114+			expect(history(await response.text()).length).toBe(3);115+		}116+	});117+109118 	test("checkpoints never appear", async () => {110119 		expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);111120 	});

view file