69197437712d
fix: expire incomplete sync frames (PB-26-705, v5.2.8)
- time
- 2026-09-07 01:01 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-5
- session
- red team remediation
- parent
- ebd357713cd6
↓ peach-browser-69197437.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-kk-transport.test.ts+31 −0src/shared/sync-transport.ts+26 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.7",4+ "version": "5.2.8",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.2.7",5+ "version": "5.2.8",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.2.7",3+ "version": "5.2.8",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/sync-kk-transport.test.ts+31 −0
@@ -466,3 +466,34 @@466466 await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);467467 });468468 });469+470+describe("PB-26-705 continuation deadline", () => {471+ it("aborts an incomplete continued frame at one fixed deadline", async () => {472+ vi.useFakeTimers();473+ let reads = 0;474+ const close = vi.fn();475+ const session = buildEncryptedSyncSession(476+ { close } as unknown as WebSocket,477+ {478+ encrypt: (bytes: Uint8Array) => bytes,479+ decrypt: () => new Uint8Array([1, 0]),480+ close: vi.fn(),481+ } as unknown as Parameters<typeof buildEncryptedSyncSession>[1],482+ "00".repeat(32),483+ () => {484+ reads += 1;485+ return reads === 1 ? Promise.resolve(new Uint8Array(1)) : new Promise(() => undefined);486+ },487+ () => undefined,488+ 100,489+ );490+491+ const pending = session.receive();492+ const rejection = expect(pending).rejects.toThrow(/reassembly timeout/i);493+ await Promise.resolve();494+ await vi.advanceTimersByTimeAsync(100);495+ await rejection;496+ expect(close).toHaveBeenCalledWith(1008, "sync frame timeout");497+ vi.useRealTimers();498+ });499+});src/shared/sync-transport.ts+26 −2
@@ -61,6 +61,7 @@6161 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;6262 const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;6363 export const RELAY_WAITER_KEEPALIVE_MS = 20_000;64+export const SYNC_REASSEMBLY_TIMEOUT_MS = 30_000;6465 6566 export interface SyncSession {6667 send(plaintext: Uint8Array): void;@@ -95,6 +96,13 @@9596 }9697 }9798 99+export class SyncFrameReassemblyTimeoutError extends Error {100+ constructor() {101+ super("sync frame reassembly timeout");102+ this.name = "SyncFrameReassemblyTimeoutError";103+ }104+}105+98106 export interface SyncSessionOptions {99107 connectTimeoutMs?: number;100108 handshakeTimeoutMs?: number;@@ -479,6 +487,7 @@479487 expectedRemotePubkeyHex: string,480488 receiveCiphertext: () => Promise<Uint8Array>,481489 beforeClose: () => void = () => undefined,490+ reassemblyTimeoutMs = SYNC_REASSEMBLY_TIMEOUT_MS,482491 ): SyncSession {483492 return {484493 async send(plaintext: Uint8Array) {@@ -526,12 +535,24 @@526535 // — the padded-frame length header comes inside the first chunk's527536 // payload, so we read it there to learn the total.528537 let buffered: Uint8Array | null = null;529- while (true) {530- const ct = await receiveCiphertext();538+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;539+ let deadline: Promise<never> | undefined;540+ try {541+ while (true) {542+ const ct = await (deadline543+ ? Promise.race([receiveCiphertext(), deadline])544+ : receiveCiphertext());531545 const pt = session.decrypt(ct);532546 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {533547 // Strip the continuation byte and keep the payload.534548 buffered = pt.subarray(1);549+ deadline = new Promise<never>((_resolve, reject) => {550+ deadlineTimer = setTimeout(() => {551+ session.close();552+ try { ws.close(1008, "sync frame timeout"); } catch { /* ignore */ }553+ reject(new SyncFrameReassemblyTimeoutError());554+ }, reassemblyTimeoutMs);555+ });535556 continue;536557 }537558 if (buffered !== null) {@@ -567,6 +588,9 @@567588 }568589 // Single-chunk frame, exactly as before.569590 return unpadSyncFrame(pt);591+ }592+ } finally {593+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);570594 }571595 },572596 async close() {