5e75cc36d81a

fix: PCH-01-014 clamp Codex PDF page count (MAX_PDF_PAGES=250) and per-page render dimensions (A4-at-max-scale budget) — caps decode work a crafted PDF can demand (v3.8.1)

time
2026-06-10 19:57 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
8989d7885b21
2 files src/popup/codex/pdf-import.clamp.test.ts+55 −0src/popup/codex/pdf-import.ts+33 −3
src/popup/codex/pdf-import.clamp.test.ts+55 −0
@@ -0,0 +1,55 @@1+import { afterEach, describe, expect, it, vi } from "vitest";2+3+// PCH-01-014: bound attacker-controlled page count and per-page render4+// dimensions. Runs in the default node environment (no `document`), so5+// renderPageToCanvas throws before any canvas is allocated — exactly the6+// behaviour that lets us assert the clamps fire (or don't) by inspecting which7+// per-page error surfaces.8+9+function mockPdf(numPages: number, viewport: { width: number; height: number }) {10+  const getPage = vi.fn(async () => ({ getViewport: () => viewport }));11+  vi.doMock("pdfjs-dist", () => ({12+    GlobalWorkerOptions: { workerSrc: "stub" },13+    getDocument: () => ({ promise: Promise.resolve({ numPages, getPage }) }),14+  }));15+  return getPage;16+}17+18+function fakeFile(size = 1000): File {19+  return { size, arrayBuffer: async () => new ArrayBuffer(8) } as unknown as File;20+}21+22+describe("PCH-01-014 — Codex PDF page/dimension clamps", () => {23+  afterEach(() => {24+    vi.resetModules();25+    vi.doUnmock("pdfjs-dist");26+  });27+28+  it("importCodexPDF truncates page count at MAX_PDF_PAGES", async () => {29+    const getPage = mockPdf(1000, { width: 600, height: 800 });30+    const { importCodexPDF } = await import("./pdf-import");31+    const result = await importCodexPDF(fakeFile());32+    // 3 probe pages (detectRenderScale) + at most 250 main-loop pages.33+    expect(getPage.mock.calls.length).toBeLessThanOrEqual(253);34+    expect(result.errors.some((e) => e.includes("only the first 250"))).toBe(true);35+  });36+37+  it("renderPageToCanvas rejects an oversized viewport before allocating", async () => {38+    mockPdf(1, { width: 14400 * 6, height: 14400 * 6 });39+    const { importCodexPDF } = await import("./pdf-import");40+    const result = await importCodexPDF(fakeFile());41+    // The dimension guard throws before document.createElement, so the page42+    // error is the render-limit message, not a "document is not defined".43+    expect(result.errors.some((e) => e.includes("render limit"))).toBe(true);44+  });45+46+  it("does not fire the dimension clamp on a normal-sized page", async () => {47+    mockPdf(3, { width: 600, height: 800 });48+    const { importCodexPDF } = await import("./pdf-import");49+    const result = await importCodexPDF(fakeFile());50+    // No render-limit rejection for a legitimate A4-ish page, and no51+    // page-count truncation message for a small document.52+    expect(result.errors.some((e) => e.includes("render limit"))).toBe(false);53+    expect(result.errors.some((e) => e.includes("only the first"))).toBe(false);54+  });55+});

view file

src/popup/codex/pdf-import.ts+33 −3
@@ -114,6 +114,17 @@114114  */115115 const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;116116 117+// PCH-01-014: bound attacker-controlled page count and page dimensions. A real118+// Codex needs ~200 pages; 250 is generous headroom.119+const MAX_PDF_PAGES = 250;120+// Largest render scale we ever probe — derived from CANDIDATE_SCALES so the121+// dimension clamp can never drift from the scale set.122+const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES);123+// A4 in points is 595×842. At MAX_RENDER_SCALE the long axis is ~5052 px; ×1.5124+// slack tolerates Letter / landscape / slightly larger legitimate layouts while125+// rejecting the 14400-pt MediaBox pathological case (~tens of GB RGBA).126+const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);127+117128 interface CellDecodeStrategy {118129   cropFactor: number;119130   normalize: boolean;@@ -218,9 +229,20 @@218229   scale: number,219230 ): Promise<HTMLCanvasElement> {220231   const viewport = page.getViewport({ scale });232+  // PCH-01-014: refuse pages whose scaled dimensions blow past a generous233+  // A4-at-max-scale budget. A tiny PDF can declare a 14400×14400 pt MediaBox,234+  // which at this scale would allocate a multi-GB RGBA canvas. The throw lands235+  // in the page loop's existing catch, which records and skips the page.236+  const w = Math.ceil(viewport.width);237+  const h = Math.ceil(viewport.height);238+  if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {239+    throw new Error(240+      `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,241+    );242+  }221243   const canvas = document.createElement("canvas");222-  canvas.width = Math.ceil(viewport.width);223-  canvas.height = Math.ceil(viewport.height);244+  canvas.width = w;245+  canvas.height = h;224246   const ctx = canvas.getContext("2d");225247   if (!ctx) {226248     return canvas;@@ -296,7 +318,15 @@296318     };297319   }298320 299-  const pageCount = pdf.numPages;321+  // PCH-01-014: cap pages so a PDF declaring thousands of pages cannot wedge322+  // the popup in a multi-minute render loop.323+  const declaredPages = pdf.numPages;324+  const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);325+  if (declaredPages > MAX_PDF_PAGES) {326+    errors.push(327+      `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,328+    );329+  }300330   const renderScale = await detectRenderScale(pdf);301331 302332   for (let pageNum = 1; pageNum <= pageCount; pageNum++) {

view file