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
↓ kepr-1972104e.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/bucket/repo.ts+46 −16tests/bucket.test.ts+67 −0package.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": {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"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+});