386e53180a98

fix: MV-4 decompression bomb caps — declared-length + cumulative section caps in peachPayload, zstd output cap, bounded streaming inflate in Codex generate

time
2026-06-10 02:13 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
b529572d23c6
4 files src/crypto/decompression-bomb.test.ts+41 −0src/crypto/peachPayload.ts+15 −0src/crypto/zstd.ts+15 −2src/popup/codex/generate.ts+26 −2
src/crypto/decompression-bomb.test.ts+41 −0
@@ -0,0 +1,41 @@1+// MV-4: decompression-bomb output bounds on the zstd and pako legs.2+import { describe, expect, it } from "vitest";3+import { deflate, inflate } from "pako";4+import { compressZstd, decompressZstd } from "./zstd";5+import { IMPORT_LIMITS } from "../shared/import-limits";6+import { inflateBounded } from "../popup/codex/generate";7+8+describe("MV-4 decompressZstd output cap", () => {9+  it("throws IMPORT_TOO_LARGE when the decompressed output exceeds the cap", async () => {10+    // Zeros compress to a tiny frame but decompress past the 64 MiB ceiling.11+    const huge = new Uint8Array(IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES + 1);12+    const compressed = await compressZstd(huge);13+    expect(compressed.byteLength).toBeLessThan(huge.byteLength);14+    await expect(decompressZstd(compressed)).rejects.toThrow(/IMPORT_TOO_LARGE/);15+  });16+17+  it("decompresses a normal payload unchanged (cap does not break legit use)", async () => {18+    const payload = new TextEncoder().encode("a normal little vault section");19+    const round = await decompressZstd(await compressZstd(payload));20+    expect(round).toEqual(payload);21+  });22+});23+24+describe("MV-4 inflateBounded", () => {25+  it("throws when inflated output exceeds the supplied ceiling (deflate bomb)", () => {26+    // 8 MiB of zeros deflates tiny; cap it at 1 MiB to trip the ceiling.27+    const bomb = deflate(new Uint8Array(8 * 1024 * 1024));28+    expect(bomb.length).toBeLessThan(1024 * 1024);29+    expect(() => inflateBounded(bomb, 1024 * 1024)).toThrow(/IMPORT_TOO_LARGE/);30+  });31+32+  it("returns bytes identical to one-shot inflate for an in-bounds payload", () => {33+    const original = new TextEncoder().encode(34+      JSON.stringify({ entries: Array.from({ length: 200 }, (_, i) => ({ id: i })) }),35+    );36+    const compressed = deflate(original);37+    const bounded = inflateBounded(compressed, IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES);38+    expect(bounded).toEqual(inflate(compressed));39+    expect(bounded).toEqual(original);40+  });41+});

view file

src/crypto/peachPayload.ts+15 −0
@@ -32,6 +32,7 @@3232 import { argon2id, blake3 } from "hash-wasm";3333 3434 import { compressZstd, decompressZstd } from "./zstd";35+import { IMPORT_LIMITS, importTooLarge } from "../shared/import-limits";3536 import type {3637   Canary,3738   CardNetwork,@@ -269,11 +270,25 @@269270     entries: [],270271     settings: {},271272   };273+  let totalDecompressed = 0;272274   for (let i = 0; i < sectionCount; i++) {273275     if (cursor + 9 > body.length) throw new Error("PAYLOAD_TOO_SHORT");274276     const type = body[cursor];275277     const length = view.getUint32(cursor + 1, true);276278     const clength = view.getUint32(cursor + 5, true);279+    // MV-4: `length` is attacker-declared. Refuse before decompressing —280+    // never let a 100-byte section promise (and zstd deliver) gigabytes.281+    if (length > IMPORT_LIMITS.MAX_DECLARED_LENGTH) {282+      throw importTooLarge(283+        `declared section size ${length} exceeds ${IMPORT_LIMITS.MAX_DECLARED_LENGTH}`,284+      );285+    }286+    // MV-4: cap the total decompressed budget across all sections too, so287+    // many maximal sections can't multiply past the single-section cap.288+    totalDecompressed += length;289+    if (totalDecompressed > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {290+      throw importTooLarge("total decompressed payload exceeds limit");291+    }277292     const dataStart = cursor + 9;278293     const dataEnd = dataStart + clength;279294     if (dataEnd > body.length) throw new Error("PAYLOAD_TOO_SHORT");

view file

src/crypto/zstd.ts+15 −2
@@ -12,6 +12,7 @@1212   decompress as zstdDecompress,1313   init as zstdInit,1414 } from "@bokuweb/zstd-wasm";15+import { IMPORT_LIMITS, importTooLarge } from "../shared/import-limits";1516 1617 let initPromise: Promise<void> | null = null;1718 @@ -31,8 +32,20 @@3132   return zstdCompress(bytes, level);3233 }3334 34-/** Decompress a zstd frame. Throws if the frame is malformed. */35+/**36+ * Decompress a zstd frame. Throws if the frame is malformed or if the37+ * output exceeds IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES (MV-4 — @bokuweb/38+ * zstd-wasm materialises the whole output, so the post-hoc check is the39+ * strongest available here; callers must ALSO bound the declared length40+ * before calling).41+ */3542 export async function decompressZstd(bytes: Uint8Array): Promise<Uint8Array> {3643   await ensureInit();37-  return zstdDecompress(bytes);44+  const out = zstdDecompress(bytes);45+  if (out.byteLength > IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES) {46+    throw importTooLarge(47+      `decompressed payload ${out.byteLength} bytes exceeds ${IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES}`,48+    );49+  }50+  return out;3851 }

view file

src/popup/codex/generate.ts+26 −2
@@ -13,7 +13,8 @@1313  * user's only escape hatch is poisoned.1414  */1515 16-import { deflate, inflate } from "pako";16+import { deflate, inflate, Inflate } from "pako";17+import { IMPORT_LIMITS } from "../../shared/import-limits";1718 import {1819   CHUNK_SIZE,1920   CODES_PER_PAGE,@@ -219,9 +220,11 @@219220 220221   // Trailing zero-pad bytes are tolerated by pako's inflate after the deflate221222   // stream end marker, so we can hand it the concatenated buffer directly.223+  // MV-4: stream the inflate with a byte counter — one-shot inflate() has no224+  // output bound and a crafted Codex payload could otherwise balloon to GiB.222225   let inflated: Uint8Array;223226   try {224-    inflated = inflate(combined);227+    inflated = inflateBounded(combined, IMPORT_LIMITS.MAX_DECOMPRESSED_BYTES);225228   } catch (err) {226229     throw new CodexError(227230       "RECONSTRUCTION_SIMULATION_FAILED",@@ -312,6 +315,27 @@312315     entryCount: liveEntryCount,313316     vaultFingerprint: fingerprint,314317   };318+}319+320+/** Inflate with a hard output ceiling — aborts mid-stream past maxBytes.321+ *  Exported for MV-4 bound tests. */322+export function inflateBounded(input: Uint8Array, maxBytes: number): Uint8Array {323+  const chunks: Uint8Array[] = [];324+  let total = 0;325+  const inf = new Inflate();326+  inf.onData = (chunk: Uint8Array) => {327+    total += chunk.length;328+    if (total > maxBytes) {329+      throw new Error(`IMPORT_TOO_LARGE: inflated output exceeds ${maxBytes} bytes`);330+    }331+    chunks.push(chunk);332+  };333+  inf.push(input, true);334+  if (inf.err) throw new Error(inf.msg || `inflate error ${inf.err}`);335+  const out = new Uint8Array(total);336+  let off = 0;337+  for (const c of chunks) { out.set(c, off); off += c.length; }338+  return out;315339 }316340 317341 function assertCompressionRoundTrip(compressed: Uint8Array, expectedJson: string): void {

view file