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
↓ peach-browser-ac667447.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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",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 () => {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+});