c067510e1a14
fix: enforce MAX_FRAME_BYTES on the final chunk of sync frame reassembly (audit PB-26-602, v5.1.12)
- time
- 2026-09-06 14:55 UTC · 1 month ago
- machine
- koh2
- agent
- openrouter/tencent/hy4-preview
- session
- PB-26-601..611 audit closeout
- parent
- cf2649c42e87
↓ peach-browser-c067510e.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+35 −0src/shared/sync-transport.ts+8 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.1.11",4+ "version": "5.1.12",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.11",5+ "version": "5.1.12",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.11",3+ "version": "5.1.12",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/sync-kk-transport.test.ts+35 −0
@@ -1,6 +1,7 @@11 import { afterEach, describe, expect, it, vi } from "vitest";22 import { generateKeyPair } from "@stablelib/x25519";33 import { noiseKKResponder } from "./noise-js";4+import { SYNC_LIMITS } from "./sync-limits";45 import { padSyncFrame, unpadSyncFrame } from "./sync-padding";56 import {67 buildEncryptedSyncSession,@@ -429,5 +430,39 @@429430 })(),430431 ).rejects.toBeInstanceOf(SyncFrameTooLargeError);431432 expect(received).toBeLessThan(400);433+ });434+});435+436+/**437+ * PB-26-602 — the continuation loop bounds only what is already buffered, so438+ * the FINAL chunk could push the reassembled frame up to one Noise message439+ * (~64 KiB) past MAX_FRAME_BYTES. The bound has to cover the concatenation.440+ */441+describe("PB-26-602 final-chunk bound", () => {442+ it("rejects a final chunk that overshoots MAX_FRAME_BYTES", async () => {443+ const max = SYNC_LIMITS.MAX_FRAME_BYTES;444+ let calls = 0;445+ const noopSession = {446+ encrypt: (pt: Uint8Array) => pt,447+ decrypt: () => {448+ calls += 1;449+ if (calls === 1) {450+ // Continuation chunk filling the buffer to exactly the cap.451+ const pt = new Uint8Array(max + 1);452+ pt[0] = 0x01; // FRAME_CONTINUATION_MARKER453+ return pt;454+ }455+ return new Uint8Array(1);456+ },457+ close: () => undefined,458+ };459+ const session = buildEncryptedSyncSession(460+ {} as WebSocket,461+ noopSession as unknown as Parameters<typeof buildEncryptedSyncSession>[1],462+ "00".repeat(32),463+ async () => new Uint8Array(0),464+ );465+466+ await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);432467 });433468 });src/shared/sync-transport.ts+8 −2
@@ -505,8 +505,14 @@505505 }506506 continue;507507 }508- // Final chunk. Concatenate without a continuation byte, then fall509- // through to the unpadder.508+ // Final chunk. PB-26-602: the continuation loop only bounds what is509+ // buffered, so the last chunk could push the frame up to 64 KiB past510+ // the cap. Bound the concatenation, not just the buffer.511+ if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {512+ throw new SyncFrameTooLargeError(513+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,514+ );515+ }510516 const reassembled = new Uint8Array(buffered.length + pt.byteLength);511517 reassembled.set(buffered, 0);512518 reassembled.set(pt, buffered.length);