b529572d23c6
fix: MV-3 sync receive bounds — transport frame cap, coordinator pre-parse cap, adapter entry-count/per-envelope/tombstone limits
- time
- 2026-06-10 02:09 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- 56ea59d6835a
↓ peach-browser-b529572d.tar.gzatlas at this save
files changed4 files
src/background/chirp-sync-coordinator.ts+7 −0src/background/chirp-sync-vault-adapter.test.ts+39 −0src/background/chirp-sync-vault-adapter.ts+26 −3src/shared/chirp-sync-transport.ts+11 −0src/background/chirp-sync-coordinator.ts+7 −0
@@ -20,6 +20,7 @@2020 2121 import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";2222 import type { ChirpSyncSession } from "../shared/chirp-sync-transport";23+import { SYNC_LIMITS } from "../shared/sync-limits";2324 2425 const SESSION_TIMEOUT_MS = 30_000;2526 @@ -276,6 +277,12 @@276277 timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));277278 });278279 const bytes = await Promise.race([pending, aborted]);280+ // MV-3: decrypted plaintext can exceed the wire frame (and a non-WS281+ // transport may not have enforced anything) — re-check before JSON.parse282+ // materialises a multi-GB string/object graph.283+ if (bytes.length > SYNC_LIMITS.MAX_FRAME_BYTES) {284+ throw new Error(`sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`);285+ }279286 const parsed = JSON.parse(new TextDecoder().decode(bytes));280287 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");281288 return parsed as ChirpSyncMessage;src/background/chirp-sync-vault-adapter.test.ts+39 −0
@@ -208,6 +208,45 @@208208 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();209209 });210210 211+ // --- MV-3: sync receive bounds ---212+213+ it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {214+ const { adapter } = await setupVault();215+ const { SYNC_LIMITS } = await import("../shared/sync-limits");216+ const tooMany: Record<string, string> = {};217+ for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";218+ await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);219+ });220+221+ it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {222+ const { vault, adapter } = await setupVault();223+ const { SYNC_LIMITS } = await import("../shared/sync-limits");224+ const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });225+ await vault.updateEntry(local.id, { title: "Updated" });226+ const envelopes = await adapter.getEncryptedEntries([local.id]);227+ // Make the local copy stale so the valid envelope would win on merge.228+ const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };229+ await vault.restoreEntries([older], "replace");230+ expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");231+232+ const oversized = "A".repeat(233+ Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,234+ );235+ await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });236+237+ // The valid envelope applied; the oversized one was skipped (never decrypted).238+ expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");239+ expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);240+ });241+242+ it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {243+ const { adapter } = await setupVault();244+ const { SYNC_LIMITS } = await import("../shared/sync-limits");245+ const tooMany: Record<string, number> = {};246+ for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;247+ await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);248+ });249+211250 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {212251 const { adapter } = await setupVault();213252 const { upsertPairedDevice } = await import("./paired-devices");src/background/chirp-sync-vault-adapter.ts+26 −3
@@ -47,7 +47,7 @@4747 listPairedDevices,4848 upsertPairedDevice,4949 } from "./paired-devices";50-import { clampSyncTimestamp } from "../shared/sync-limits";50+import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";5151 import type { VaultEntry } from "../shared/types";5252 import type {5353 ManifestDeviceEntry,@@ -126,12 +126,28 @@126126 127127 async applyEntries(entries) {128128 requireUnlocked();129+ const entryList = Object.entries(entries);130+ // MV-3: bound the batch before any per-entry crypto work.131+ if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {132+ throw new Error(133+ `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,134+ );135+ }129136 const masterKey = requireMasterKey();130137 const decrypted: VaultEntry[] = [];131- for (const [id, envelope] of Object.entries(entries)) {138+ for (const [id, envelope] of entryList) {139+ // MV-3: per-entry ceiling, checked on the (base64) envelope before140+ // decrypting. Base64 inflates by 4/3, so allow that factor.141+ if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {142+ continue; // oversized envelope — skip, never decrypt143+ }132144 const key = await deriveEntryKey(masterKey, id);133145 const entry = await decryptEntryOrNull(key, envelope);134146 if (!entry) continue;147+ // MV-3: defense in depth — re-measure the decrypted plaintext.148+ if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {149+ continue;150+ }135151 // The map key is the authority on id — refuse mismatched envelopes136152 // so a swapped envelope can't poison a different entry slot.137153 if (entry.id !== id) continue;@@ -144,8 +160,15 @@144160 145161 async applyTombstones(tombstones) {146162 requireUnlocked();163+ const tombstoneList = Object.entries(tombstones);164+ // MV-3: bound the tombstone batch before processing.165+ if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {166+ throw new Error(167+ `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,168+ );169+ }147170 const cleaned: Record<string, number> = {};148- for (const [id, ts] of Object.entries(tombstones)) {171+ for (const [id, ts] of tombstoneList) {149172 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {150173 // HV-1c: clamp before the value participates in any comparison.151174 // vault.ts clamps again defensively; clamping here keeps thesrc/shared/chirp-sync-transport.ts+11 −0
@@ -19,6 +19,7 @@1919 // under ~10 seconds.2020 2121 import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";22+import { SYNC_LIMITS } from "./sync-limits";2223 import { hexToPubkey, pubkeyToHex } from "./sync-identity";2324 2425 const DEFAULT_PROBE_TIMEOUT_MS = 500;@@ -64,6 +65,16 @@6465 ws.onmessage = (ev: MessageEvent) => {6566 const data = ev.data;6667 if (data instanceof ArrayBuffer) {68+ // MV-3: refuse oversized frames before they are buffered or decrypted.69+ // A peer that sends one is misbehaving — tear the session down rather70+ // than letting the inbox grow toward OOM.71+ if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {72+ errors.push(new Error(73+ `sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`,74+ ));75+ try { ws.close(); } catch { /* ignore */ }76+ return;77+ }6778 const bytes = new Uint8Array(data);6879 const r = readers.shift();6980 if (r) r(bytes);