b73f541c166b

write backup download entries 0600 through a file mode on the tar writer, keeping source tarballs 0644, and refuse signatures over 60 KB before the body cap (v3.0.41)

time
2026-09-26 20:35 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
dbc8eb3d4b8a
6 files package.json+1 −1src/admin/site.ts+5 −0src/server/backup.ts+2 −1src/tarball/tar.ts+9 −5tests/backup.test.ts+7 −1tests/console.test.ts+11 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.40",3+	"version": "3.0.41",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/site.ts+5 −0
@@ -20,6 +20,8 @@2020 ];2121 const HANDLE = /^[A-Za-z0-9._-]+$/;2222 const HTTP_URL = /^https?:\/\/[^\s]+$/i;23+// Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.24+const MAX_SIGNATURE = 60 * 1024;2325 const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;2426 2527 /**@@ -55,6 +57,9 @@5557 		}5658 		const upload = (await c.req.parseBody()).signature;5759 		const previous = setting(ctx.kepr, "signature");60+		if (upload instanceof File && upload.size > MAX_SIGNATURE) {61+			return refuse("The signature must be under 60 KB.");62+		}5863 		if (upload instanceof File && upload.size > 0) {5964 			const bytes = new Uint8Array(await upload.arrayBuffer());6065 			const type = imageType(bytes);

view file

src/server/backup.ts+2 −1
@@ -91,5 +91,6 @@9191 			const path = join(entry.parentPath, entry.name);9292 			return { path: relative(dir, path), bytes: readFileSync(path) };9393 		});94-	return { name, bytes: buildTarball(`kepr-backup-${name}`, files) };94+	// Owner-only once unpacked: kepr.db holds reporters' contact emails.95+	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };9596 }

view file

src/tarball/tar.ts+9 −5
@@ -31,16 +31,20 @@3131 /**3232  * A deterministic `.tar.gz` of `files` under `root/`, byte for byte what Kepr 2 wrote: one directory3333  * entry for the root, then the files in byte order of their paths, all 0644, with zero owners and34- * times (§22.3: the headers match Kepr 2's, so an executable bit is not kept).34+ * times (§22.3: the headers match Kepr 2's, so an executable bit is not kept). A backup passes 0o600.3535  */36-export function buildTarball(root: string, files: TarFile[]): Uint8Array<ArrayBuffer> {36+export function buildTarball(37+	root: string,38+	files: TarFile[],39+	mode = 0o644,40+): Uint8Array<ArrayBuffer> {3741 	const entries = files3842 		.filter((file) => file.path !== "" && !isStripped(file.path))3943 		.map((file) => ({ ...file, name: encoder.encode(`${root}/${file.path}`) }))4044 		.sort((a, b) => Buffer.compare(a.name, b.name));4145 	const blocks: Uint8Array[] = [header(encoder.encode(root), 0, true)];4246 	for (const entry of entries) {43-		blocks.push(header(entry.name, entry.bytes.length, false));47+		blocks.push(header(entry.name, entry.bytes.length, false, mode));4448 		blocks.push(entry.bytes, new Uint8Array((BLOCK - (entry.bytes.length % BLOCK)) % BLOCK));4549 	}4650 	blocks.push(new Uint8Array(2 * BLOCK));@@ -52,7 +56,7 @@5256 5357 // A POSIX ustar header, as Kepr 2's `tarWriteHeader`: names over 99 bytes (98 for a directory, which5458 // needs its trailing slash) split at a '/' into the 155-byte prefix field, never truncated.55-function header(name: Uint8Array, size: number, isDir: boolean): Uint8Array {59+function header(name: Uint8Array, size: number, isDir: boolean, mode = 0o644): Uint8Array {5660 	const h = new Uint8Array(BLOCK);5761 	const maxName = isDir ? 98 : 99;5862 	let base = name;@@ -66,7 +70,7 @@6670 	h.set(base);6771 	if (isDir) h[base.length] = 0x2f;6872 	const ascii = (text: string, at: number) => h.set(encoder.encode(text), at);69-	ascii(isDir ? "0000755\0" : "0000644\0", 100);73+	ascii(isDir ? "0000755\0" : `${mode.toString(8).padStart(7, "0")}\0`, 100);7074 	ascii("0000000\0", 108);7175 	ascii("0000000\0", 116);7276 	if (!isDir) ascii(`${size.toString(8).padStart(11, "0")}\0`, 124);

view file

tests/backup.test.ts+7 −1
@@ -1,5 +1,5 @@11 import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2-import { mkdtemp, readdir, writeFile } from "node:fs/promises";2+import { mkdtemp, readdir, stat, writeFile } from "node:fs/promises";33 import { tmpdir } from "node:os";44 import { join } from "node:path";55 import { fileIssue } from "../src/issues/store.ts";@@ -41,6 +41,7 @@4141 	let bucketDir: string;4242 	let signature = "";4343 	let extracted: string[] = [];44+	let modes: number[] = [];4445 4546 	beforeAll(async () => {4647 		setSystemTime(new Date(clock));@@ -101,6 +102,10 @@101102 		const tar = Bun.spawnSync(["tar", "-xzf", "backup.tar.gz"], { cwd: work });102103 		expect(tar.exitCode).toBe(0);103104 		const restored = join(work, "kepr-backup-20260926T120100Z");105+		modes = [106+			(await stat(join(restored, "kepr.db"))).mode & 0o777,107+			(await stat(join(restored, "assets", signature))).mode & 0o777,108+		];104109 		extracted = (await readdir(restored, { recursive: true })).sort();105110 		// Mirrors are not in the backup; the fresh instance's first sync rebuilds them.106111 		fresh = await instance(bucketDir, restored);@@ -110,6 +115,7 @@110115 	const text = async (path: string) => (await fresh.app.request(path)).text();111116 112117 	test("the backup holds kepr.db and assets, and no sessions", async () => {118+		expect(modes).toEqual([0o600, 0o600]);113119 		expect(extracted).toEqual(["assets", `assets/${signature}`, "kepr.db"]);114120 		expect(fresh.kepr.query("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 });115121 		expect(await (await operator.get("/admin")).text()).toContain(

view file

tests/console.test.ts+11 −0
@@ -394,6 +394,17 @@394394 		expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);395395 		expect(signature()).toEqual(before);396396 		expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);397+		// Between the 60 KB limit and the 64 KiB body cap, the limit answers.398+		const png = (size: number) => {399+			const bytes = new Uint8Array(size);400+			bytes.set(PNG);401+			return bytes;402+		};403+		const over = await upload(png(60 * 1024 + 1), "big.png", "image/png");404+		expect(over.status).toBe(400);405+		expect(await over.text()).toContain("The signature must be under 60 KB.");406+		expect(signature()).toEqual(before);407+		expect((await upload(png(60 * 1024), "edge.png", "image/png")).status).toBe(303);397408 	});398409 399410 	test("a new signature replaces the old file; removing it brings the handle back", async () => {

view file