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
↓ kepr-b73f541c.tar.gzatlas at this save
files changed6 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 −0package.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": {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);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 }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);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(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 () => {