efefabc50cb3
fix: enforce relay-safe sync frame limits (v4.0.39)
- time
- 2026-07-26 02:27 UTC · 3 months ago
- machine
- koh2
- agent
- gpt-5
- session
- peach relay sync implementation
- parent
- 157f3326d8f1
↓ peach-browser-efefabc5.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-limits.ts+5 −2src/shared/sync-transport.ts+31 −4src/shared/sync.disconnect.test.ts+34 −9manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.38",4+ "version": "4.0.39",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.38",4+ "version": "4.0.39",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.0.38",3+ "version": "4.0.39",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/sync-limits.ts+5 −2
@@ -30,8 +30,11 @@3030 * able to OOM the service worker with one frame or one giant manifest.3131 */3232 export const SYNC_LIMITS = {33- /** Max bytes per decrypted Noise frame, checked before JSON.parse. */34- MAX_FRAME_BYTES: 50 * 1024 * 1024, // 50 MiB33+ /**34+ * Max bytes per WebSocket/Noise frame. Cloudflare Workers closes messages35+ * above 32 MiB; 24 MiB leaves transport overhead and platform margin.36+ */37+ MAX_FRAME_BYTES: 24 * 1024 * 1024, // 24 MiB3538 /** Max entries accepted in one SYNC_ENTRIES / manifest payload. */3639 MAX_ENTRY_COUNT: 100_000,3740 /** Max plaintext size of one decrypted entry envelope. */src/shared/sync-transport.ts+31 −4
@@ -58,6 +58,13 @@5858 signingPublicKeyHex?: string;5959 }6060 61+export class SyncFrameTooLargeError extends Error {62+ constructor(message = "sync frame too large (WebSocket close 1009)") {63+ super(message);64+ this.name = "SyncFrameTooLargeError";65+ }66+}67+6168 function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {6269 return new TextEncoder().encode(JSON.stringify({6370 type: "PAIR_IDENTITY",@@ -87,6 +94,7 @@8794 const inbox: Uint8Array[] = [];8895 const readers: Array<(b: Uint8Array) => void> = [];8996 let closed = false;97+ let closeError: Error = new Error("WebSocket closed");9098 9199 ws.binaryType = "arraybuffer";92100 ws.onmessage = (ev: MessageEvent) => {@@ -102,7 +110,10 @@102110 console.warn(103111 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,104112 );105- try { ws.close(); } catch { /* ignore */ }113+ closeError = new SyncFrameTooLargeError(114+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,115+ );116+ try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }106117 return;107118 }108119 const bytes = new Uint8Array(data);@@ -118,8 +129,11 @@118129 console.warn("[Peach] sync WebSocket error; closing transport");119130 try { ws.close(); } catch { /* ignore */ }120131 };121- ws.onclose = () => {132+ ws.onclose = (event: CloseEvent) => {122133 closed = true;134+ if (event.code === 1009) {135+ closeError = new SyncFrameTooLargeError();136+ }123137 while (readers.length > 0) {124138 const r = readers.shift()!;125139 r(new Uint8Array(0));@@ -128,6 +142,14 @@128142 129143 function send(bytes: Uint8Array): Promise<void> {130144 return new Promise((resolve, reject) => {145+ if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {146+ reject(147+ new SyncFrameTooLargeError(148+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,149+ ),150+ );151+ return;152+ }131153 try {132154 ws.send(bytes as Uint8Array<ArrayBuffer>);133155 resolve();@@ -137,12 +159,12 @@137159 });138160 }139161 function receive(): Promise<Uint8Array> {140- if (closed) return Promise.reject(new Error("WebSocket closed before frame"));162+ if (closed) return Promise.reject(closeError);141163 const v = inbox.shift();142164 if (v) return Promise.resolve(v);143165 return new Promise<Uint8Array>((resolve, reject) => {144166 readers.push((b) => {145- if (b.length === 0 && closed) reject(new Error("WebSocket closed"));167+ if (b.length === 0 && closed) reject(closeError);146168 else resolve(b);147169 });148170 });@@ -173,6 +195,11 @@173195 return {174196 send(plaintext: Uint8Array) {175197 const ciphertext = session.encrypt(plaintext);198+ if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {199+ throw new SyncFrameTooLargeError(200+ `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,201+ );202+ }176203 ws.send(ciphertext as Uint8Array<ArrayBuffer>);177204 },178205 async receive() {src/shared/sync.disconnect.test.ts+34 −9
@@ -38,14 +38,14 @@3838 binaryType: string;3939 onmessage: ((ev: MessageEvent) => void) | null;4040 onerror: (() => void) | null;41- onclose: ((ev: Event) => void) | null;41+ onclose: ((ev: CloseEvent) => void) | null;4242 onopen: ((ev: Event) => void) | null;4343 sentFrames: ArrayBuffer[];4444 send(data: ArrayBuffer | Uint8Array): void;45- close(): void;45+ close(code?: number, reason?: string): void;4646 simulateOpen(): void;4747 simulateError(): void;48- simulateClose(): void;48+ simulateClose(code?: number): void;4949 simulateMessage(data: ArrayBuffer): void;5050 }5151 @@ -64,9 +64,9 @@6464 instance.sentFrames.push(data);6565 }6666 },67- close() {67+ close(code = 1000) {6868 // Simulate the browser firing onclose after close() is called69- Promise.resolve().then(() => instance.simulateClose());69+ Promise.resolve().then(() => instance.simulateClose(code));7070 },7171 simulateOpen() {7272 instance.onopen?.call(instance, new Event("open"));@@ -74,8 +74,8 @@7474 simulateError() {7575 instance.onerror?.call(instance);7676 },77- simulateClose() {78- instance.onclose?.call(instance, new Event("close"));77+ simulateClose(code = 1000) {78+ instance.onclose?.call(instance, { code } as CloseEvent);7979 },8080 simulateMessage(data: ArrayBuffer) {8181 instance.onmessage?.call(instance, { data } as MessageEvent);@@ -224,12 +224,37 @@224224 mockWs!.simulateOpen();225225 await new Promise((r) => setTimeout(r, 0));226226 227- // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (50 MiB)227+ // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB)228228 const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);229229 mockWs!.simulateMessage(bigFrame);230230 231- await expect(sessionPromise).rejects.toThrow(/closed|error/i);231+ await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);232232 233+ vi.unstubAllGlobals();234+ });235+236+ it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {237+ let mockWs: MockWebSocketInstance;238+ const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {239+ mockWs = createMockWebSocket();240+ return mockWs;241+ });242+ vi.stubGlobal("WebSocket", MockWebSocketCtor);243+244+ const peerKp = generateKeyPair();245+ const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");246+ const sessionPromise = openSyncSession(247+ { ip: "127.0.0.1", port: 9999 },248+ TEST_IDENTITY,249+ peerHex,250+ );251+252+ await new Promise((r) => setTimeout(r, 0));253+ mockWs!.simulateOpen();254+ await new Promise((r) => setTimeout(r, 0));255+ mockWs!.simulateClose(1009);256+257+ await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);233258 vi.unstubAllGlobals();234259 });235260 });