ac6674472a01

add bounded SYNC_REQUEST and import size guard regression tests (PB-26-111, PB-26-112, v5.0.50)

time
2026-09-02 06:49 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
d485bbc3e255
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+84 −0src/popup/views/Import.test.tsx+124 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.49",4+  "version": "5.0.50",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.0.49",5+  "version": "5.0.50",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.0.49",3+  "version": "5.0.50",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-coordinator.test.ts+84 −0
@@ -1,4 +1,5 @@11 import { describe, expect, it, vi } from "vitest";2+import { SYNC_LIMITS } from "../shared/sync-limits";23 import {34   runSyncInitiator,45   runSyncResponder,@@ -121,6 +122,89 @@121122     appliedTombstones,122123   };123124 }125+126+// PB-26-111: SYNC_REQUEST.entryIds was bounded only by the 24 MiB frame size —127+// roughly a million UUIDs — while each requested id costs an HKDF derivation128+// plus an AES-GCM encryption of the entry envelope. An authenticated peer could129+// burn ~30s of service-worker CPU per dial, repeatably, with no data effect that130+// would look like an attack.131+describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {132+  it("rejects an oversized id list before any per-entry work", async () => {133+    const [session, peer] = inMemoryPair();134+    const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });135+    let encryptedFor: string[] | null = null;136+    const spied = Object.assign({}, vault, {137+      async getEncryptedEntries(ids: string[]) {138+        encryptedFor = ids;139+        return {};140+      },141+    });142+143+    const initPromise = runSyncInitiator(session, spied);144+    void (async () => {145+      await peer.receive(); // hello146+      await peer.send({147+        type: "SYNC_HELLO",148+        vaultId: "vault-abc",149+        deviceId: "p",150+        version: 0,151+        deviceTime: Date.now(),152+      });153+      await peer.receive(); // our manifest154+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });155+      await peer.receive(); // our request156+      // Ask for more ids than the protocol allows.157+      await peer.send({158+        type: "SYNC_REQUEST",159+        entryIds: Array.from(160+          { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },161+          (_, i) => `id-${i}`,162+        ),163+      });164+    })();165+166+    const outcome = await initPromise;167+    expect(outcome.status).toBe("errored");168+    expect(outcome.error).toMatch(/entry count exceeds limit/);169+    // The whole point: the bound fires before the expensive per-id work.170+    expect(encryptedFor).toBeNull();171+  });172+173+  it("still serves a request at the limit", async () => {174+    const [session, peer] = inMemoryPair();175+    const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);176+    const vault = makeVaultAdapter({177+      vaultId: "vault-abc",178+      entries: { "id-0": 1, "id-1": 1, "id-2": 1 },179+      ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },180+    });181+182+    const initPromise = runSyncInitiator(session, vault);183+    void (async () => {184+      await peer.receive(); // hello185+      await peer.send({186+        type: "SYNC_HELLO",187+        vaultId: "vault-abc",188+        deviceId: "p",189+        version: 0,190+        deviceTime: Date.now(),191+      });192+      await peer.receive(); // our manifest193+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });194+      await peer.receive(); // our request195+      await peer.send({ type: "SYNC_REQUEST", entryIds: ids });196+      await peer.receive(); // SYNC_ENTRIES197+      await peer.receive(); // SYNC_TOMBSTONES198+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });199+      await peer.receive(); // SYNC_DONE200+      await peer.send({ type: "SYNC_DONE" });201+    })();202+203+    const outcome = await initPromise;204+    expect(outcome.status).toBe("completed");205+    expect(outcome.entriesSent).toBe(3);206+  });207+});124208 125209 describe("runSyncInitiator — HELLO fields and version optimisation", () => {126210   it("sends HELLO with version and deviceTime", async () => {

view file

src/popup/views/Import.test.tsx+124 −0
@@ -0,0 +1,124 @@1+// @vitest-environment jsdom2+//3+// PB-26-112: the 10 MiB import ceiling used to be enforced AFTER the file was4+// materialised as a string, but only on the CSV/JSON branch — the .peach picker5+// checked `file.size` first. A multi-gigabyte CSV was therefore read into a6+// popup string before any validation ran.7+//8+// Both branches now check `file.size` before `file.text()`. These tests assert9+// the rejection path with a stubbed File: the stub's text() records the call, so10+// "refused without reading it" is directly observable rather than inferred.11+12+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";13+14+const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));15+vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));16+17+import { IMPORT_LIMITS } from "../../shared/import-limits";18+import { ImportView } from "./Import";19+import { flushView, renderView } from "./view-test-utils";20+21+let mounted: Array<{ dispose: () => void }> = [];22+23+/**24+ * A File-shaped stub large enough to exceed the ceiling. `size` is the only25+ * field the guards read; `text()` is what must never be called.26+ */27+function oversizedFile(name: string): File & { readCalls: number } {28+  const file = {29+    name,30+    size: IMPORT_LIMITS.MAX_JSON_BYTES + 1,31+    type: "text/csv",32+    readCalls: 0,33+    async text() {34+      file.readCalls += 1;35+      return "";36+    },37+    async arrayBuffer() {38+      file.readCalls += 1;39+      return new ArrayBuffer(0);40+    },41+  } as unknown as File & { readCalls: number };42+  return file;43+}44+45+function attachOversized(input: HTMLInputElement, file: File): void {46+  Object.defineProperty(input, "files", {47+    configurable: true,48+    get: () => [file],49+  });50+}51+52+beforeEach(() => {53+  mounted = [];54+  mockSendMessage.mockReset();55+  mockSendMessage.mockImplementation(() => Promise.resolve({ ok: true, data: null }));56+  (globalThis as unknown as { chrome: unknown }).chrome = {57+    runtime: {58+      onMessage: { addListener: () => undefined, removeListener: () => undefined },59+      getURL: (path: string) => `chrome-extension://test/${path}`,60+    },61+    storage: {62+      local: {63+        get: () => Promise.resolve({}),64+        set: () => Promise.resolve(),65+        remove: () => Promise.resolve(),66+      },67+      session: {68+        get: () => Promise.resolve({}),69+        set: () => Promise.resolve(),70+        remove: () => Promise.resolve(),71+      },72+    },73+  };74+});75+76+afterEach(() => {77+  for (const view of mounted) view.dispose();78+  mounted = [];79+  vi.restoreAllMocks();80+});81+82+describe("PB-26-112: import branches bound the file before reading it", () => {83+  it("the CSV/JSON picker refuses an oversized file without reading it", async () => {84+    const view = renderView(() => (85+      <ImportView initialView="file" onBack={() => undefined} onImport={() => undefined} />86+    ));87+    mounted.push(view);88+    await flushView();89+90+    const picker = view.container.querySelector<HTMLInputElement>(91+      'input[type="file"][accept=".csv,.json,.peach,application/x-peach"]',92+    );93+    expect(picker, "expected a CSV/JSON file input").not.toBeNull();94+95+    const file = oversizedFile("huge.csv");96+    attachOversized(picker!, file);97+    picker!.dispatchEvent(new Event("change", { bubbles: true }));98+    await flushView();99+100+    expect(file.readCalls).toBe(0);101+    expect(view.container.textContent).toContain("too large");102+  });103+104+  it("the .peach picker refuses an oversized file without reading it", async () => {105+    const view = renderView(() => (106+      <ImportView initialView="peachscript" onBack={() => undefined} onImport={() => undefined} />107+    ));108+    mounted.push(view);109+    await flushView();110+111+    const picker = view.container.querySelector<HTMLInputElement>(112+      'input[type="file"][accept=".peach,application/x-peach,text/plain"]',113+    );114+    expect(picker, "expected a .peach file input").not.toBeNull();115+116+    const file = oversizedFile("huge.peach");117+    attachOversized(picker!, file);118+    picker!.dispatchEvent(new Event("change", { bubbles: true }));119+    await flushView();120+121+    expect(file.readCalls).toBe(0);122+    expect(view.container.textContent).toContain("too large");123+  });124+});

view file