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
↓ peach-browser-386e5318.tar.gzatlas at this save
files changed4 files
src/crypto/decompression-bomb.test.ts+41 −0src/crypto/peachPayload.ts+15 −0src/crypto/zstd.ts+15 −2src/popup/codex/generate.ts+26 −2src/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+});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");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 }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 {