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
6 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 −0
manifest.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": {

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.2.6",5+  "version": "5.2.7",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.2.6",3+  "version": "5.2.7",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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. */

view file

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);

view file

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()

view file