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
6 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 −5
manifest.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": {

view file

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",

view file

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",

view file

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 /**

view file

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 });

view file

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,

view file