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
↓ peach-browser-80f09eb7.tar.gzatlas at this save
files changed5 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 −20manifest.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": {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",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",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 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