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
↓ peach-browser-a7d691b5.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/pairing-dial.test.ts+212 −0src/shared/pairing-dial.ts+44 −4manifest.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": {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",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",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+});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 },