80f09eb7e4bd

fix: cap aggregate Codex PDF rendering (PB-26-704, v5.2.11)

time
2026-09-07 01:14 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
red team remediation
parent
317f2df9a402
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/codex/pdf-import.test.ts+11 −0src/popup/codex/pdf-import.ts+67 −20
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.10",4+  "version": "5.2.11",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.10",5+  "version": "5.2.11",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.10",3+  "version": "5.2.11",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/codex/pdf-import.test.ts+11 −0
@@ -35,6 +35,8 @@3535 import { buildChunkEnvelope } from "./helpers";3636 import {3737   cellRectFor,38+  claimPDFRenderPixels,39+  createPDFRenderBudget,3840   decodeAztecFromArgb,3941   parseChunkEnvelope,4042 } from "./pdf-import";@@ -48,6 +50,15 @@4850   PAGE_WIDTH,4951   ROW_STEP,5052 } from "./pdf-layout";53+54+describe("PB-26-704 PDF aggregate render budget", () => {55+  it("rejects cumulative near-limit pages before another canvas allocation", () => {56+    const budget = createPDFRenderBudget(100);57+    claimPDFRenderPixels(budget, 10, 5);58+    claimPDFRenderPixels(budget, 5, 10);59+    expect(() => claimPDFRenderPixels(budget, 1, 1)).toThrow(/aggregate render limit/i);60+  });61+});5162 5263 // ── parseChunkEnvelope ──────────────────────────────────────────────────────5364 

view file

src/popup/codex/pdf-import.ts+67 −20
@@ -122,7 +122,30 @@122122 // slack tolerates Letter / landscape / slightly larger legitimate layouts while123123 // rejecting the 14400-pt MediaBox pathological case (~tens of GB RGBA).124124 const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);125+const MAX_PDF_RENDER_PIXELS = 2_000_000_000;125126 127+export interface PDFRenderBudget {128+  remainingPixels: number;129+}130+131+class PDFRenderBudgetError extends Error {}132+133+export function createPDFRenderBudget(maxPixels = MAX_PDF_RENDER_PIXELS): PDFRenderBudget {134+  return { remainingPixels: maxPixels };135+}136+137+export function claimPDFRenderPixels(138+  budget: PDFRenderBudget,139+  width: number,140+  height: number,141+): void {142+  const pixels = width * height;143+  if (pixels > budget.remainingPixels) {144+    throw new PDFRenderBudgetError("PDF aggregate render limit exceeded");145+  }146+  budget.remainingPixels -= pixels;147+}148+126149 interface CellDecodeStrategy {127150   cropFactor: number;128151   normalize: boolean;@@ -166,6 +189,7 @@166189  */167190 async function detectRenderScale(168191   pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,192+  budget: PDFRenderBudget,169193 ): Promise<number> {170194   let bestScale: number = CANDIDATE_SCALES[0];171195   let bestRate = -1;@@ -177,7 +201,7 @@177201       let total = 0;178202       for (let pageNum = 1; pageNum <= probePages; pageNum++) {179203         const page = await pdf.getPage(pageNum);180-        const result = await probePageDecodeRate(page, scale);204+        const result = await probePageDecodeRate(page, scale, budget);181205         decoded += result.decoded;182206         total += result.total;183207       }@@ -189,7 +213,8 @@189213       if (rate >= 0.99) {190214         return scale;191215       }192-    } catch {216+    } catch (error) {217+      if (error instanceof PDFRenderBudgetError) throw error;193218       // continue to next candidate194219     }195220   }@@ -200,31 +225,38 @@200225 async function probePageDecodeRate(201226   page: PdfPage,202227   scale: number,228+  budget: PDFRenderBudget,203229 ): Promise<{ decoded: number; total: number }> {204-  const canvas = await renderPageToCanvas(page, scale);205-  const ctx = canvas.getContext("2d");206-  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };230+  const canvas = await renderPageToCanvas(page, scale, budget);231+  try {232+    const ctx = canvas.getContext("2d");233+    if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };207234 208-  let decoded = 0;209-  for (let row = 0; row < CODES_PER_COL; row++) {210-    for (let col = 0; col < CODES_PER_ROW; col++) {211-      const rect = cellRectFor(canvas.width, canvas.height, row, col);212-      if (!rect.sw || !rect.sh) continue;213-      const text = decodeAztecCellRobust(214-        canvas,215-        ctx,216-        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },217-        scale,218-      );219-      if (text !== null) decoded++;235+    let decoded = 0;236+    for (let row = 0; row < CODES_PER_COL; row++) {237+      for (let col = 0; col < CODES_PER_ROW; col++) {238+        const rect = cellRectFor(canvas.width, canvas.height, row, col);239+        if (!rect.sw || !rect.sh) continue;240+        const text = decodeAztecCellRobust(241+          canvas,242+          ctx,243+          { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },244+          scale,245+        );246+        if (text !== null) decoded++;247+      }220248     }249+    return { decoded, total: CODES_PER_PAGE };250+  } finally {251+    canvas.width = 0;252+    canvas.height = 0;221253   }222-  return { decoded, total: CODES_PER_PAGE };223254 }224255 225256 async function renderPageToCanvas(226257   page: PdfPage,227258   scale: number,259+  budget: PDFRenderBudget,228260 ): Promise<HTMLCanvasElement> {229261   const viewport = page.getViewport({ scale });230262   // PCH-01-014: refuse pages whose scaled dimensions blow past a generous@@ -238,6 +270,7 @@238270       `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,239271     );240272   }273+  claimPDFRenderPixels(budget, w, h);241274   const canvas = document.createElement("canvas");242275   canvas.width = w;243276   canvas.height = h;@@ -290,6 +323,7 @@290323   // each stripe's declared total once and sum after the scan so a multi-stripe291324   // Codex reports the true global count, not just stripe 0's.292325   const stripeTotalsForExpected = new Map<number, number>();326+  const renderBudget = createPDFRenderBudget();293327 294328   let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;295329   try {@@ -328,14 +362,21 @@328362       `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,329363     );330364   }331-  const renderScale = await detectRenderScale(pdf);365+  let renderScale: number;366+  try {367+    renderScale = await detectRenderScale(pdf, renderBudget);368+  } catch (error) {369+    errors.push(error instanceof Error ? error.message : String(error));370+    return { chunksFound: 0, totalExpected: null, envelopes, errors };371+  }332372 333373   for (let pageNum = 1; pageNum <= pageCount; pageNum++) {334374     onProgress?.(pageNum, pageCount);335375 376+    let canvas: HTMLCanvasElement | undefined;336377     try {337378       const page = await pdf.getPage(pageNum);338-      const canvas = await renderPageToCanvas(page, renderScale);379+      canvas = await renderPageToCanvas(page, renderScale, renderBudget);339380       const ctx = canvas.getContext("2d");340381       if (!ctx) {341382         errors.push(`Page ${pageNum}: no canvas context`);@@ -352,6 +393,12 @@352393       }353394     } catch (err) {354395       errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);396+      if (err instanceof PDFRenderBudgetError) break;397+    } finally {398+      if (canvas) {399+        canvas.width = 0;400+        canvas.height = 0;401+      }355402     }356403   }357404 

view file