1972104e46af

validate paths, ids and fields of bucket save records before use (v3.0.5)

time
2026-09-26 16:15 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
3f94f3d4aead
3 files package.json+1 −1src/bucket/repo.ts+46 −16tests/bucket.test.ts+67 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.4",3+	"version": "3.0.5",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/repo.ts+46 −16
@@ -26,6 +26,7 @@2626 const SAVE_ID = /^[0-9a-f]{12}$/;2727 const OBJECT_HASH = /^[0-9a-f]{64}$/;2828 const REPO_NAME = /^[A-Za-z0-9._-]+$/;29+const CONTROL_CHAR = /\p{Cc}/u;2930 const decoder = new TextDecoder();3031 3132 // ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.@@ -34,11 +35,25 @@3435 	return keys3536 		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)3637 		.map((key) => key.slice(0, -"/head".length))37-		.filter((name) => REPO_NAME.test(name) && name !== "." && name !== "..");38+		.filter(isRepoName);3839 }3940 41+function isRepoName(name: string): boolean {42+	return REPO_NAME.test(name) && name !== "." && name !== "..";43+}44+45+// Checked here rather than in route handlers, so no caller can build a key from unchecked input.46+function requireRepo(repo: string): void {47+	if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);48+}49+50+function requireSaveId(id: string): void {51+	if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);52+}53+4054 /** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */4155 export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {56+	requireRepo(repo);4257 	const bytes = await bucket.get(`${repo}/format`);4358 	if (bytes === null) return;4459 	const format = decoder.decode(bytes).trim();@@ -48,6 +63,7 @@4863 }4964 5065 export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {66+	requireRepo(repo);5167 	const bytes = await bucket.get(`${repo}/head`);5268 	if (bytes === null) return null;5369 	const head = decoder.decode(bytes).trim();@@ -60,6 +76,8 @@6076 	repo: string,6177 	id: string,6278 ): Promise<SaveRecord> {79+	requireRepo(repo);80+	requireSaveId(id);6381 	const bytes = await bucket.get(`${repo}/saves/${id}`);6482 	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);6583 	const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));@@ -73,6 +91,7 @@7391 	repo: string,7492 	hash: string,7593 ): Promise<Uint8Array> {94+	requireRepo(repo);7695 	if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);7796 	const compressed = await bucket.get(`${repo}/objects/${hash}`);7897 	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);@@ -85,36 +104,47 @@85104 86105 function parseSaveRecord(value: unknown): SaveRecord {87106 	const r = value as Record<string, unknown>;88-	const strings = [89-		"id",90-		"parent",91-		"message",92-		"machine",93-		"agent",94-		"session",95-		"restores",96-		"replayed_from",97-	];98107 	const valid =99108 		typeof value === "object" &&100109 		value !== null &&101-		strings.every((key) => typeof r[key] === "string") &&102-		SAVE_ID.test(r.id as string) &&103-		typeof r.created_at === "number" &&104-		typeof r.size_bytes === "number" &&110+		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&111+		typeof r.id === "string" &&112+		SAVE_ID.test(r.id) &&113+		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&114+		isCount(r.created_at) &&115+		isCount(r.size_bytes) &&105116 		typeof r.is_checkpoint === "boolean" &&106117 		Array.isArray(r.files) &&107-		r.files.every(isSaveFile);118+		r.files.every(isSaveFile) &&119+		new Set(r.files.map((file) => file.path)).size === r.files.length;108120 	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");109121 	return value as SaveRecord;110122 }111123 124+function isOptionalSaveId(value: unknown): boolean {125+	return value === "" || (typeof value === "string" && SAVE_ID.test(value));126+}127+128+function isCount(value: unknown): boolean {129+	return Number.isSafeInteger(value) && (value as number) >= 0;130+}131+132+/** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */133+function isSafePath(path: string): boolean {134+	return (135+		!path.includes("\\") &&136+		!CONTROL_CHAR.test(path) &&137+		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")138+	);139+}140+112141 function isSaveFile(value: unknown): value is SaveFile {113142 	const f = value as Record<string, unknown>;114143 	return (115144 		typeof value === "object" &&116145 		value !== null &&117146 		typeof f.path === "string" &&147+		isSafePath(f.path) &&118148 		typeof f.hash === "string" &&119149 		OBJECT_HASH.test(f.hash) &&120150 		typeof f.executable === "boolean"

view file

tests/bucket.test.ts+67 −0
@@ -111,3 +111,70 @@111111 		);112112 	});113113 });114+115+describe("untrusted input", () => {116+	// Starts from a genuine koh3 record and corrupts one field, as a tampered bucket would.117+	async function readTampered(change: (record: Record<string, unknown>) => void) {118+		const id = fx.saveIds[0] as string;119+		const real = await bucket().get(`${fx.repo}/saves/${id}`);120+		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));121+		change(record);122+		const bytes = new TextEncoder().encode(JSON.stringify(record));123+		return readSave({ get: async () => bytes, list: async () => [] }, fx.repo, id);124+	}125+	const withPath = (path: string) => (record: Record<string, unknown>) => {126+		(record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };127+	};128+129+	test("the genuine record is accepted", async () => {130+		expect((await readTampered(() => {})).message).toBe("say hello");131+	});132+133+	test.each([134+		"",135+		"/etc/passwd",136+		"../escape",137+		"src/../../escape",138+		"./src/main.ts",139+		"src//main.ts",140+		"src/",141+		"src\\main.ts",142+		"src/ma\0in.ts",143+		"src/ma\nin.ts",144+	])("refuses the file path %p", async (path) => {145+		await expect(readTampered(withPath(path))).rejects.toThrow("Koh 3 schema");146+	});147+148+	test("refuses duplicate paths in one record", async () => {149+		const dupe = (record: Record<string, unknown>) => {150+			const files = record.files as { path: string }[];151+			files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };152+		};153+		await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");154+	});155+156+	test.each([157+		["parent", "not-an-id"],158+		["restores", "ABCDEF123456"],159+		["replayed_from", "../../../x"],160+		["created_at", -1],161+		["created_at", 1.5],162+		["size_bytes", -10],163+		["size_bytes", "12"],164+	])("refuses %s = %p", async (field, value) => {165+		await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");166+	});167+168+	test("refuses bad repo names and save IDs before building a key", async () => {169+		const b = bucket();170+		for (const repo of ["..", "a/b", "", "../demo"]) {171+			await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");172+			await expect(readHead(b, repo)).rejects.toThrow("not a repo name");173+			await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");174+			await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");175+		}176+		for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {177+			await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");178+		}179+	});180+});

view file