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
↓ peach-browser-5e75cc36.tar.gzatlas at this save
files changed2 files
src/popup/codex/pdf-import.clamp.test.ts+55 −0src/popup/codex/pdf-import.ts+33 −3src/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+});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++) {