a7d691b51e23

bound pairing relay inbox with frame/inbox ceilings (PB-26-205, v5.0.61)

time
2026-09-03 10:43 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-205 pairing inbox limits
parent
e5dc1e260ba7
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/pairing-dial.test.ts+212 −0src/shared/pairing-dial.ts+44 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.60",4+  "version": "5.0.61",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.0.60",5+  "version": "5.0.61",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.0.60",3+  "version": "5.0.61",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/pairing-dial.test.ts+212 −0
@@ -0,0 +1,212 @@1+import { afterEach, describe, expect, it, vi } from "vitest";2+import {3+  MAX_PAIRING_FRAME_BYTES,4+  MAX_PAIRING_INBOX_BYTES,5+  MAX_PAIRING_INBOX_MESSAGES,6+  PAIRING_RESOURCE_LIMIT,7+  openRelayPairingSocket,8+  type PairingSocket,9+} from "./pairing-dial";10+11+// PB-26-205: the relay pairing inbox must be bounded before any12+// allocate/copy of attacker-controlled frame data.13+14+interface FakeSocket {15+  binaryType: string;16+  onmessage: ((ev: { data: unknown }) => void) | null;17+  onerror: (() => void) | null;18+  onclose: ((ev: { code: number }) => void) | null;19+  onopen: (() => void) | null;20+  sent: unknown[];21+  closeCalls: Array<{ code?: number; reason?: string }>;22+  send(data: unknown): void;23+  close(code?: number, reason?: string): void;24+  simulateOpen(): void;25+  simulateMessage(data: unknown): void;26+  simulateClose(code?: number): void;27+}28+29+let fake: FakeSocket;30+31+function createFake(): FakeSocket {32+  const sock: FakeSocket = {33+    binaryType: "",34+    onmessage: null,35+    onerror: null,36+    onclose: null,37+    onopen: null,38+    sent: [],39+    closeCalls: [],40+    send(data: unknown) {41+      sock.sent.push(data);42+    },43+    close(code?: number, reason?: string) {44+      sock.closeCalls.push({ code, reason });45+      // Like a real browser, firing onclose asynchronously after close().46+      queueMicrotask(() => sock.simulateClose(code));47+    },48+    simulateOpen() {49+      sock.onopen?.();50+    },51+    simulateMessage(data: unknown) {52+      sock.onmessage?.({ data });53+    },54+    simulateClose(code = 1000) {55+      sock.onclose?.({ code });56+    },57+  };58+  return sock;59+}60+61+async function openSocket(): Promise<PairingSocket> {62+  const pending = openRelayPairingSocket("wss://relay.example.test/room");63+  fake.simulateOpen();64+  return pending;65+}66+67+function frame(n: number): ArrayBuffer {68+  return new Uint8Array(n).buffer as ArrayBuffer;69+}70+71+afterEach(() => {72+  vi.unstubAllGlobals();73+});74+75+describe("PB-26-205 pairing relay inbox limits", () => {76+  it("accepts a frame of exactly MAX_PAIRING_FRAME_BYTES", async () => {77+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {78+      fake = createFake();79+      return fake;80+    }));81+    const sock = await openSocket();82+    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES));83+    const got = await sock.receive();84+    expect(got.byteLength).toBe(MAX_PAIRING_FRAME_BYTES);85+    expect(fake.closeCalls).toEqual([]);86+  });87+88+  it("rejects a max+1 frame with a 1009 close before copying", async () => {89+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {90+      fake = createFake();91+      return fake;92+    }));93+    const sock = await openSocket();94+    const pending = sock.receive();95+    const rejection = expect(pending).rejects.toThrow(PAIRING_RESOURCE_LIMIT);96+    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES + 1));97+    await rejection;98+    expect(fake.closeCalls[0]?.code).toBe(1009);99+    // Queued references are released: nothing remains deliverable.100+    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);101+  });102+103+  it("rejects non-ArrayBuffer frames without copying", async () => {104+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {105+      fake = createFake();106+      return fake;107+    }));108+    const sock = await openSocket();109+    const pending = sock.receive();110+    const rejection = expect(pending).rejects.toThrow(PAIRING_RESOURCE_LIMIT);111+    fake.simulateMessage("not-binary");112+    await rejection;113+    expect(fake.closeCalls[0]?.code).toBe(1009);114+  });115+116+  it("closes when individually valid frames exceed the inbox byte budget", async () => {117+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {118+      fake = createFake();119+      return fake;120+    }));121+    const sock = await openSocket();122+    const frames = Math.floor(MAX_PAIRING_INBOX_BYTES / MAX_PAIRING_FRAME_BYTES);123+    for (let i = 0; i < frames; i++) fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES));124+    // One more byte over budget trips the limit even though the frame is valid.125+    fake.simulateMessage(frame(1));126+    expect(fake.closeCalls[0]?.code).toBe(1009);127+    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);128+  });129+130+  it("enforces the message-count limit with tiny frames", async () => {131+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {132+      fake = createFake();133+      return fake;134+    }));135+    const sock = await openSocket();136+    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) fake.simulateMessage(frame(1));137+    expect(fake.closeCalls).toEqual([]);138+    fake.simulateMessage(frame(1));139+    expect(fake.closeCalls[0]?.code).toBe(1009);140+    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);141+  });142+143+  it("dequeue relieves the budget so later valid frames are accepted", async () => {144+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {145+      fake = createFake();146+      return fake;147+    }));148+    const sock = await openSocket();149+    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) fake.simulateMessage(frame(1));150+    const first = await sock.receive();151+    expect(first.byteLength).toBe(1);152+    // Count and byte budget both dropped by one: this frame fits again.153+    fake.simulateMessage(frame(1));154+    expect(fake.closeCalls).toEqual([]);155+    const rest: number[] = [];156+    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) rest.push((await sock.receive()).byteLength);157+    expect(rest).toHaveLength(MAX_PAIRING_INBOX_MESSAGES);158+  });159+160+  it("gives every pending reader the same terminal error and ignores later events", async () => {161+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {162+      fake = createFake();163+      return fake;164+    }));165+    const sock = await openSocket();166+    const r1 = sock.receive();167+    const r2 = sock.receive();168+    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES + 1));169+    await expect(r1).rejects.toThrow(PAIRING_RESOURCE_LIMIT);170+    await expect(r2).rejects.toThrow(PAIRING_RESOURCE_LIMIT);171+    // Later frames and the resulting onclose are ignored: still one close.172+    fake.simulateMessage(frame(1));173+    fake.simulateClose(1009);174+    await new Promise((r) => setTimeout(r, 0));175+    expect(fake.closeCalls).toHaveLength(1);176+    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);177+    expect(sock.closedBeforeFrame()).toBe(false);178+  });179+180+  it("refuses oversized outbound frames before sending", async () => {181+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {182+      fake = createFake();183+      return fake;184+    }));185+    const sock = await openSocket();186+    expect(() => sock.send(new Uint8Array(frame(MAX_PAIRING_FRAME_BYTES + 1)))).toThrow(187+      PAIRING_RESOURCE_LIMIT,188+    );189+    expect(fake.sent).toHaveLength(0);190+    // The socket stays usable for honest frames.191+    sock.send(new Uint8Array(frame(16)));192+    expect(fake.sent).toHaveLength(1);193+  });194+195+  it("keeps normal pairing and wrong-code behavior unchanged", async () => {196+    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {197+      fake = createFake();198+      return fake;199+    }));200+    const sock = await openSocket();201+    fake.simulateMessage(frame(64));202+    fake.simulateMessage(frame(128));203+    expect((await sock.receive()).byteLength).toBe(64);204+    expect((await sock.receive()).byteLength).toBe(128);205+    // Wrong/expired code: silent close with nothing queued.206+    const pending = sock.receive();207+    const rejection = expect(pending).rejects.toThrow("socket closed");208+    fake.simulateClose(1000);209+    await rejection;210+    expect(sock.closedBeforeFrame()).toBe(true);211+  });212+});

view file

src/shared/pairing-dial.ts+44 −4
@@ -16,6 +16,15 @@1616   closedBeforeFrame(): boolean;1717 }1818 19+// PB-26-205: transport backstop enforced before allocating/copying frame20+// data. The 1 MiB frame ceiling clears the 768 KiB adoption frame ceiling21+// with headroom; the 12 MiB / 256-message inbox clears the 10 MiB aggregate22+// adoption budget plus handshake/control overhead.23+export const MAX_PAIRING_FRAME_BYTES = 1024 * 1024;24+export const MAX_PAIRING_INBOX_BYTES = 12 * 1024 * 1024;25+export const MAX_PAIRING_INBOX_MESSAGES = 256;26+export const PAIRING_RESOURCE_LIMIT = "PAIRING_RESOURCE_LIMIT";27+1928 /** Relay pairing uses this binary socket seam over `wss://`. */2029 export function openRelayPairingSocket(url: string): Promise<PairingSocket> {2130   return new Promise((resolve, reject) => {@@ -35,21 +44,48 @@3544     // True iff the socket closed with no frame delivered for the pending read —3645     // the wrong/expired-code signal (the listener sends no error frame).3746     let sawCloseBeforeFrame = false;47+    // PB-26-205: bytes/messages currently held in `inbox`, and the terminal48+    // limit state. Once tripped, later socket events are ignored.49+    let queuedBytes = 0;50+    let limitError: Error | null = null;3851 52+    function failLimit(): void {53+      if (limitError) return;54+      limitError = new Error(PAIRING_RESOURCE_LIMIT);55+      inbox.length = 0;56+      queuedBytes = 0;57+      while (readers.length) readers.shift()!.reject(limitError);58+      try { ws.close(1009); } catch { /* ignore */ }59+    }60+3961     ws.onopen = () => {4062       opened = true;4163       resolve(sock);4264     };4365     ws.onmessage = (ev: MessageEvent) => {44-      const bytes = new Uint8Array(ev.data as ArrayBuffer);66+      if (limitError) return;67+      // Check the length before constructing the Uint8Array copy.68+      if (!(ev.data instanceof ArrayBuffer) || ev.data.byteLength > MAX_PAIRING_FRAME_BYTES) {69+        failLimit();70+        return;71+      }72+      if (73+        inbox.length + 1 > MAX_PAIRING_INBOX_MESSAGES ||74+        queuedBytes + ev.data.byteLength > MAX_PAIRING_INBOX_BYTES75+      ) {76+        failLimit();77+        return;78+      }79+      const bytes = new Uint8Array(ev.data);4580       const r = readers.shift();4681       if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }47-      else inbox.push(bytes);82+      else { inbox.push(bytes); queuedBytes += bytes.byteLength; }4883     };4984     ws.onerror = () => {5085       if (!opened) reject(new Error("websocket connection failed"));5186     };5287     ws.onclose = () => {88+      if (limitError) return;5389       closed = true;5490       if (readers.length) {5591         sawCloseBeforeFrame = true;@@ -59,10 +95,14 @@5995     };6096 6197     const sock: PairingSocket = {62-      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },98+      send(bytes: Uint8Array) {99+        if (bytes.byteLength > MAX_PAIRING_FRAME_BYTES) throw new Error(PAIRING_RESOURCE_LIMIT);100+        ws.send(bytes as Uint8Array<ArrayBuffer>);101+      },63102       receive(): Promise<Uint8Array> {103+        if (limitError) return Promise.reject(limitError);64104         const queued = inbox.shift();65-        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }105+        if (queued) { queuedBytes -= queued.byteLength; sawCloseBeforeFrame = false; return Promise.resolve(queued); }66106         if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }67107         return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));68108       },

view file