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

view file

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",

view file

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",

view file

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

view file

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

view file

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

view file