ebd357713cd6
fix: bound sync transport inboxes (PB-26-701, v5.2.7)
- time
- 2026-09-07 00:57 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-5
- session
- red team remediation
- parent
- b86994371f4f
↓ peach-browser-ebd35771.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-limits.ts+2 −0src/shared/sync-transport.ts+53 −6src/shared/sync.disconnect.test.ts+34 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.6",4+ "version": "5.2.7",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.6",5+ "version": "5.2.7",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.6",3+ "version": "5.2.7",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/sync-limits.ts+2 −0
@@ -75,6 +75,8 @@7575 * above 32 MiB; 24 MiB leaves transport overhead and platform margin.7676 */7777 MAX_FRAME_BYTES: 24 * 1024 * 1024, // 24 MiB78+ MAX_INBOX_BYTES: 32 * 1024 * 1024, // one max frame plus bounded control traffic79+ MAX_INBOX_MESSAGES: 256,7880 /** Max entries accepted in one SYNC_ENTRIES / manifest / request payload. */7981 MAX_ENTRY_COUNT: 100_000,8082 /** Max IDs a peer may request before per-entry crypto work begins. */src/shared/sync-transport.ts+53 −6
@@ -88,6 +88,13 @@8888 }8989 }9090 91+export class SyncTransportResourceLimitError extends Error {92+ constructor() {93+ super("sync transport resource limit exceeded");94+ this.name = "SyncTransportResourceLimitError";95+ }96+}97+9198 export interface SyncSessionOptions {9299 connectTimeoutMs?: number;93100 handshakeTimeoutMs?: number;@@ -151,6 +158,7 @@151158 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,152159 );153160 const inbox: Uint8Array[] = [];161+ let inboxBytes = 0;154162 const readers: Array<(b: Uint8Array) => void> = [];155163 let closed = false;156164 let closeError: Error = new Error("WebSocket closed");@@ -165,6 +173,7 @@165173 166174 ws.binaryType = "arraybuffer";167175 ws.onmessage = (event: MessageEvent) => {176+ if (closed) return;168177 if (!(event.data instanceof ArrayBuffer)) return;169178 if (event.data.byteLength === 0) return;170179 if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {@@ -177,7 +186,19 @@177186 const bytes = new Uint8Array(event.data);178187 const reader = readers.shift();179188 if (reader) reader(bytes);180- else inbox.push(bytes);189+ else if (190+ inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||191+ inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES192+ ) {193+ closeError = new SyncTransportResourceLimitError();194+ closed = true;195+ inbox.length = 0;196+ inboxBytes = 0;197+ try { ws.close(1009, "sync inbox limit"); } catch { /* ignore */ }198+ } else {199+ inbox.push(bytes);200+ inboxBytes += bytes.byteLength;201+ }181202 };182203 ws.onerror = () => {183204 try { ws.close(); } catch { /* ignore */ }@@ -186,7 +207,11 @@186207 clearInterval(keepalive);187208 options.signal?.removeEventListener("abort", abortWaiter);188209 closed = true;189- if (event.code === 1009) closeError = new SyncFrameTooLargeError();210+ inbox.length = 0;211+ inboxBytes = 0;212+ if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {213+ closeError = new SyncFrameTooLargeError();214+ }190215 while (readers.length > 0) readers.shift()!(new Uint8Array(0));191216 };192217 // Install close handling before honoring an already-aborted signal so a@@ -205,7 +230,10 @@205230 const receive = (): Promise<Uint8Array> => {206231 if (closed) return Promise.reject(closeError);207232 const buffered = inbox.shift();208- if (buffered) return Promise.resolve(buffered);233+ if (buffered) {234+ inboxBytes -= buffered.byteLength;235+ return Promise.resolve(buffered);236+ }209237 return new Promise<Uint8Array>((resolve, reject) => {210238 readers.push((bytes) => {211239 if (bytes.length === 0 && closed) reject(closeError);@@ -290,12 +318,14 @@290318 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,291319 );292320 const inbox: Uint8Array[] = [];321+ let inboxBytes = 0;293322 const readers: Array<(b: Uint8Array) => void> = [];294323 let closed = false;295324 let closeError: Error = new Error("WebSocket closed");296325 297326 ws.binaryType = "arraybuffer";298327 ws.onmessage = (ev: MessageEvent) => {328+ if (closed) return;299329 const data = ev.data;300330 if (data instanceof ArrayBuffer) {301331 // MV-3: refuse oversized frames before they are buffered or decrypted.@@ -317,7 +347,19 @@317347 const bytes = new Uint8Array(data);318348 const r = readers.shift();319349 if (r) r(bytes);320- else inbox.push(bytes);350+ else if (351+ inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||352+ inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES353+ ) {354+ closeError = new SyncTransportResourceLimitError();355+ closed = true;356+ inbox.length = 0;357+ inboxBytes = 0;358+ try { ws.close(1009, "sync inbox limit"); } catch { /* ignore */ }359+ } else {360+ inbox.push(bytes);361+ inboxBytes += bytes.byteLength;362+ }321363 }322364 };323365 ws.onerror = () => {@@ -329,7 +371,9 @@329371 };330372 ws.onclose = (event: CloseEvent) => {331373 closed = true;332- if (event.code === 1009) {374+ inbox.length = 0;375+ inboxBytes = 0;376+ if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {333377 closeError = new SyncFrameTooLargeError();334378 }335379 while (readers.length > 0) {@@ -359,7 +403,10 @@359403 function receive(): Promise<Uint8Array> {360404 if (closed) return Promise.reject(closeError);361405 const v = inbox.shift();362- if (v) return Promise.resolve(v);406+ if (v) {407+ inboxBytes -= v.byteLength;408+ return Promise.resolve(v);409+ }363410 return new Promise<Uint8Array>((resolve, reject) => {364411 readers.push((b) => {365412 if (b.length === 0 && closed) reject(closeError);src/shared/sync.disconnect.test.ts+34 −0
@@ -95,6 +95,40 @@9595 // ---------------------------------------------------------------------------9696 9797 describe("T-020: WebSocket sync-disconnect handling", () => {98+ it("PB-26-701: closes when individually valid frames overflow the queued inbox", async () => {99+ let mockWs: MockWebSocketInstance;100+ const closeSpy = vi.fn(function(this: MockWebSocketInstance, code = 1000) {101+ Promise.resolve().then(() => this.simulateClose(code));102+ });103+ vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function() {104+ mockWs = createMockWebSocket();105+ mockWs.close = closeSpy.bind(mockWs);106+ return mockWs;107+ }));108+109+ const peerKp = generateKeyPair();110+ const sessionPromise = openSyncSessionAtUrl(111+ "ws://127.0.0.1:9999/",112+ "LAN peer",113+ TEST_IDENTITY,114+ Array.from(peerKp.publicKey, (b) => b.toString(16).padStart(2, "0")).join(""),115+ TEST_RENDEZVOUS_ID,116+ );117+ await new Promise((resolve) => setTimeout(resolve, 0));118+ mockWs!.simulateOpen();119+ await new Promise((resolve) => setTimeout(resolve, 0));120+121+ // The first message satisfies the parked handshake read. The rest queue.122+ // Individually valid tiny frames must still hit a finite message budget.123+ for (let i = 0; i < 258; i++) mockWs!.simulateMessage(new ArrayBuffer(1));124+125+ expect(closeSpy).toHaveBeenCalledWith(1009, "sync inbox limit");126+ mockWs!.simulateMessage(new ArrayBuffer(1));127+ expect(closeSpy).toHaveBeenCalledTimes(1);128+ await expect(sessionPromise).rejects.toThrow();129+ vi.unstubAllGlobals();130+ });131+98132 // T-020a: onerror during handshake → session promise rejects, no hang.99133 // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()100134 // to drain the readers queue. Without this, the Noise KK initiator's receive()