b68bdc98f5f1
fix: cap import entry count during parse to bound transient memory (audit PB-26-606, v5.1.15)
- time
- 2026-09-06 15:20 UTC · 1 month ago
- machine
- koh2
- agent
- openrouter/tencent/hy4-preview
- session
- PB-26-601..611 audit closeout
- parent
- 8e348373e1b9
↓ peach-browser-b68bdc98.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/import-limits.ts+6 −0src/shared/import.test.ts+22 −0src/shared/import.ts+19 −5manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.1.14",4+ "version": "5.1.15",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.1.14",5+ "version": "5.1.15",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.1.14",3+ "version": "5.1.15",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/import-limits.ts+6 −0
@@ -16,6 +16,12 @@1616 MAX_PDF_BYTES: 64 * 1024 * 1024, // 64 MiB1717 /** Max raw JSON/CSV/PeachScript string length accepted by import/restore. */1818 MAX_JSON_BYTES: 10 * 1024 * 1024, // 10 MiB19+ /**20+ * Max entries one parse may yield. A 10 MiB input of minimal rows expands to21+ * ~25x its size in ImportableEntry objects, which is enough to kill an MV322+ * service worker. Matches the sync layer's MAX_ENTRY_COUNT.23+ */24+ MAX_IMPORT_ENTRY_COUNT: 100_000,1925 } as const;2026 2127 /**src/shared/import.test.ts+22 −0
@@ -4,6 +4,7 @@44 base64urlEncode,55 generatePasskeyKeypair,66 } from "../background/passkey-crypto";7+import { IMPORT_LIMITS } from "./import-limits";78 import { detectImportFormat, parse1PasswordJSON, parseImportSource } from "./import";89 910 describe("shared import parsing", () => {@@ -273,5 +274,26 @@273274 it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {274275 const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap275276 await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);277+ });278+});279+280+/**281+ * PB-26-606 — a 10 MiB input of minimal rows expands to ~25x its size in282+ * ImportableEntry objects, enough to exhaust an MV3 service worker. The cap283+ * must trip while entries are still being accumulated.284+ */285+describe("PB-26-606 import entry count cap", () => {286+ const csvRows = (count: number) =>287+ `name,password\n${Array.from({ length: count }, () => ",p").join("\n")}`;288+289+ it("accepts a CSV just under the cap", async () => {290+ const result = await parseImportSource(csvRows(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT - 1));291+ expect(result.entries).toHaveLength(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT - 1);292+ });293+294+ it("rejects a CSV that exceeds the cap with IMPORT_TOO_LARGE", async () => {295+ await expect(296+ parseImportSource(csvRows(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT + 1)),297+ ).rejects.toThrow("IMPORT_TOO_LARGE");276298 });277299 });src/shared/import.ts+19 −5
@@ -18,6 +18,20 @@1818 1919 export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2020 21+/**22+ * Accumulate one parsed entry, enforcing MAX_IMPORT_ENTRY_COUNT. Every parser23+ * routes through here so the cap holds while entries are still being built,24+ * not after the whole array exists.25+ */26+function pushEntry(entries: ImportableEntry[], entry: ImportableEntry): void {27+ if (entries.length >= IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT) {28+ throw importTooLarge(29+ `import exceeds ${IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT} entries`,30+ );31+ }32+ entries.push(entry);33+}34+2135 export type ImportFormat =2236 | "csv"2337 | "1password-json"@@ -146,7 +160,7 @@146160 fields.find((field: any) => field.designation === "password" || field.id === "password")?.value ??147161 "";148162 if (!password) continue;149- entries.push({163+ pushEntry(entries, {150164 type: "login",151165 title: (item.overview?.title ?? item.title)?.trim() || "Untitled",152166 username: username.trim(),@@ -219,7 +233,7 @@219233 220234 if (!password) return entries;221235 222- entries.push({236+ pushEntry(entries, {223237 type: "login",224238 title: firstNonEmptyString(title, url, username, "Untitled"),225239 username,@@ -249,7 +263,7 @@249263 const password = stringOrEmpty(item.login?.password);250264 if (!password) return entries;251265 252- entries.push({266+ pushEntry(entries, {253267 type: "login",254268 title: firstNonEmptyString(item.name, "Untitled"),255269 url: firstNonEmptyString(item.login?.uris?.[0]?.uri, ""),@@ -292,7 +306,7 @@292306 );293307 if (!password) continue;294308 295- entries.push({309+ pushEntry(entries, {296310 type: "login",297311 title: firstNonEmptyString(298312 metadata.name,@@ -373,7 +387,7 @@373387 const pkcs8 = base64urlDecode(credential.key);374388 const imported = await importPasskeyPrivateKey(pkcs8);375389 const createdAt = Date.now();376- entries.push({390+ pushEntry(entries, {377391 type: "login",378392 title: firstNonEmptyString(item.title, credential.rpId),379393 username: credential.username,