a207e6c140c2

fix: MV-5 file and string size limits on all import paths — PDF gate before read, raw-string caps in parseImportSource and importEntriesFromPeachJson

time
2026-06-10 02:16 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
386e53180a98
6 files src/popup/codex/pdf-import.test.ts+20 −1src/popup/codex/pdf-import.ts+15 −0src/shared/backup.test.ts+7 −0src/shared/backup.ts+8 −0src/shared/import.test.ts+5 −0src/shared/import.ts+8 −0
src/popup/codex/pdf-import.test.ts+20 −1
@@ -28,7 +28,7 @@2828  * has automated coverage.2929  */3030 31-import { describe, expect, it } from "vitest";31+import { describe, expect, it, vi } from "vitest";3232 3333 import type { BitMatrix } from "@zxing/library";3434 import { buildAztecMatrix } from "./aztec";@@ -359,4 +359,23 @@359359       expect(parsed!.d).toBe(envelopes[i]!.d);360360     }361361   }, 30_000);362+363+  it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => {364+    const { importCodexPDF } = await import("./pdf-import");365+    const { IMPORT_LIMITS } = await import("../../shared/import-limits");366+    const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));367+    const fakeFile = {368+      size: IMPORT_LIMITS.MAX_PDF_BYTES + 1,369+      arrayBuffer,370+    } as unknown as File;371+372+    const result = await importCodexPDF(fakeFile);373+374+    expect(result.chunksFound).toBe(0);375+    expect(result.envelopes).toEqual([]);376+    expect(result.errors).toHaveLength(1);377+    expect(result.errors[0]).toMatch(/limited to/);378+    // Never read into memory — the gate fires before file.arrayBuffer().379+    expect(arrayBuffer).not.toHaveBeenCalled();380+  });362381 });

view file

src/popup/codex/pdf-import.ts+15 −0
@@ -39,6 +39,7 @@3939   type ChunkEnvelope,4040 } from "../../shared/codex-types";4141 import { verifyChunkIntegrity } from "./helpers";42+import { IMPORT_LIMITS } from "../../shared/import-limits";4243 import {4344   CODE_SIZE,4445   COL_GAP,@@ -244,6 +245,20 @@244245   file: File,245246   onProgress?: PDFImportProgress,246247 ): Promise<PDFImportResult> {248+  // MV-5: refuse oversized files before reading them into memory — and before249+  // even loading pdf.js. A real Codex tops out well under this (200 pages ≈250+  // 4 MiB of Aztec PNGs).251+  if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {252+    return {253+      chunksFound: 0,254+      totalExpected: null,255+      envelopes: [],256+      errors: [257+        `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,258+      ],259+    };260+  }261+247262   const pdfjsLib = await getPdfJs();248263 249264   const envelopes: ChunkEnvelope[] = [];

view file

src/shared/backup.test.ts+7 −0
@@ -95,4 +95,11 @@9595       importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass")9696     ).rejects.toThrow("INVALID_PEACH_EXPORT");9797   });98+99+  it("MV-5: importEntriesFromPeachJson rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {100+    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap101+    await expect(importEntriesFromPeachJson(huge, "export-pass")).rejects.toThrow(102+      /IMPORT_TOO_LARGE/,103+    );104+  });98105 });

view file

src/shared/backup.ts+8 −0
@@ -1,4 +1,5 @@11 import { decrypt, encrypt } from "../background/aes";2+import { IMPORT_LIMITS, importTooLarge } from "./import-limits";23 import type { PeachLicenseExport, VaultEntry } from "./types";34 45 /**@@ -54,6 +55,13 @@5455   raw: string,5556   password: string5657 ): Promise<PeachJsonImportResult> {58+  // MV-5: defense in depth — parseImportSource caps first when this is59+  // reached through it, but this function is also exported directly.60+  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {61+    throw importTooLarge(62+      `peach export ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,63+    );64+  }5765   let envelope: Partial<PeachExportEnvelope>;5866   try {5967     envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;

view file

src/shared/import.test.ts+5 −0
@@ -467,4 +467,9 @@467467     expect(result.entries[0].totp).toBe("TEST123");468468     expect(result.entries[0].notes).toBe("A note");469469   });470+471+  it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {472+    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap473+    await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);474+  });470475 });

view file

src/shared/import.ts+8 −0
@@ -13,6 +13,7 @@1313   importPasskeyPrivateKey,1414 } from "../background/passkey-crypto";1515 import { parseOtpauthURI } from "../crypto/totp";16+import { IMPORT_LIMITS, importTooLarge } from "./import-limits";1617 import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";1718 1819 export type ImportFormat =@@ -41,6 +42,13 @@4142   raw: string,4243   password?: string4344 ): Promise<ImportParseResult> {45+  // MV-5: bound the input before any JSON.parse / CSV parse allocates46+  // proportional structures. Covers file picks AND pasted strings.47+  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {48+    throw importTooLarge(49+      `import source ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,50+    );51+  }4452   const format = detectImportFormat(raw);4553 4654   switch (format) {

view file