From 774aeeb313caa2314c56d4fe39b1cbe1336f2046 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:31:47 +0100 Subject: [PATCH] fix: read PDF attachments only as far as needed, with a time limit and no scripts --- lib/attachment-text/__tests__/pdf.test.ts | 70 +++++++++++++++++++++++ lib/attachment-text/index.ts | 2 +- lib/attachment-text/pdf.ts | 60 +++++++++++++------ 3 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 lib/attachment-text/__tests__/pdf.test.ts diff --git a/lib/attachment-text/__tests__/pdf.test.ts b/lib/attachment-text/__tests__/pdf.test.ts new file mode 100644 index 000000000..5b2608fe0 --- /dev/null +++ b/lib/attachment-text/__tests__/pdf.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { pdfText, setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf'; +import { extractText } from '@/lib/attachment-text'; + +// pdf.js is stood in for: each page is a list of text lines. + +const calls = { pages: 0, sources: [] as unknown[], destroyed: 0 }; + +function fakePdf(pages: string[][], { hang = false } = {}): PdfLib { + return { + getDocument: (src) => { + calls.sources.push(src); + return { + promise: hang ? new Promise(() => {}) : Promise.resolve({ + numPages: pages.length, + getPage: async (n: number) => { + calls.pages++; + return { getTextContent: async () => ({ items: pages[n - 1].map((str, i, all) => ({ str, hasEOL: i < all.length - 1 })) }) }; + }, + }), + destroy: async () => { calls.destroyed++; }, + }; + }, + }; +} + +const pdf = new TextEncoder().encode('%PDF-1.4'); + +afterEach(() => { + setPdfLoader(); + calls.pages = 0; + calls.sources.length = 0; + calls.destroyed = 0; +}); + +describe('pdfText', () => { + it('reads a typed cover letter in front of a scan', async () => { + setPdfLoader(async () => fakePdf([['Dear Alice, please find the signed contract attached.'], [''], [''], ['']])); + const out = await extractText(pdf, { name: 'scan.pdf' }); + expect(out).toMatchObject({ status: 'ok', pages: 4 }); + expect(out.text).toContain('signed contract'); + }); + + it('stops reading pages once the budget is used up', async () => { + setPdfLoader(async () => fakePdf(Array.from({ length: 50 }, (_, i) => [`Page ${i + 1}: ${'word '.repeat(200)}`]))); + const out = await pdfText(pdf, { budget: 2_500 }); + expect(out).toMatchObject({ status: 'ok', truncated: true, pages: 50 }); + expect(calls.pages).toBeLessThanOrEqual(4); + expect(out.text.length).toBeLessThanOrEqual(2_500); + }); + + it('never lets pdf.js evaluate scripts', async () => { + setPdfLoader(async () => fakePdf([['Some text that is long enough to count']])); + await pdfText(pdf); + expect(calls.sources[0]).toMatchObject({ isEvalSupported: false }); + }); + + it('gives up after the timeout and stops pdf.js', async () => { + vi.useFakeTimers(); + try { + setPdfLoader(async () => fakePdf([], { hang: true })); + const pending = pdfText(pdf, { timeoutMs: 1_000 }); + await vi.advanceTimersByTimeAsync(1_001); + expect(await pending).toMatchObject({ status: 'failed' }); + expect(calls.destroyed).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/lib/attachment-text/index.ts b/lib/attachment-text/index.ts index 3764a9822..d192c1972 100644 --- a/lib/attachment-text/index.ts +++ b/lib/attachment-text/index.ts @@ -78,7 +78,7 @@ async function read(bytes: Uint8Array, format: TextFormat, budget: number): Prom case 'vcf': return { status: 'ok', text: vcfText(decodeText(bytes)), truncated: false }; case 'pdf': - return pdfText(bytes); + return pdfText(bytes, { budget }); case 'docx': case 'xlsx': case 'pptx': case 'odt': case 'ods': case 'odp': return officeText(bytes, format, { budget }); default: diff --git a/lib/attachment-text/pdf.ts b/lib/attachment-text/pdf.ts index eabf9a925..ba51eac06 100644 --- a/lib/attachment-text/pdf.ts +++ b/lib/attachment-text/pdf.ts @@ -4,17 +4,21 @@ // refuses blob: workers). import { liteMountedAssetUrl } from '@/lib/lite'; -import { MAX_PDF_PAGES, MIN_PDF_CHARS_PER_PAGE, type ExtractedText } from './types'; +import { MAX_PDF_PAGES, MAX_TEXT_CHARS, MIN_PDF_CHARS_PER_PAGE, TEXT_BUDGET_SLACK, type ExtractedText } from './types'; +import { TextSink } from './sink'; /** The part of pdf.js used here (tests pass a stand-in). */ export interface PdfLib { - getDocument(src: { data: Uint8Array }): { promise: Promise; destroy(): Promise }; + getDocument(src: { data: Uint8Array; isEvalSupported?: boolean }): { promise: Promise; destroy(): Promise }; } interface PdfDoc { numPages: number; getPage(n: number): Promise<{ getTextContent(): Promise<{ items: unknown[] }> }>; } +/** Longest a PDF may take to read before the read gives up. */ +export const PDF_TIMEOUT_MS = 30_000; + async function loadPdfJs(): Promise { const pdfjs = await import('pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = liteMountedAssetUrl(new URL( @@ -44,36 +48,56 @@ function pageText(items: unknown[]): string { return out.replace(/[ \t]+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim(); } +class TimedOut extends Error {} + +export interface PdfOptions { + /** Characters to read at most; reading stops at the page that fills it. */ + budget?: number; + /** Longest the read may take (default PDF_TIMEOUT_MS). */ + timeoutMs?: number; +} + /** - * The text of at most MAX_PDF_PAGES pages, a "Page n" line before each. - * no_text when the pages hold fewer than MIN_PDF_CHARS_PER_PAGE characters - * on average (a scan); encrypted for a PDF that needs a password. + * The text of at most MAX_PDF_PAGES pages, a "Page n" line before each, + * stopping once `budget` characters are read. no_text when no page has + * MIN_PDF_CHARS_PER_PAGE characters (a scan; a typed cover letter in front + * of one is read); encrypted for a PDF that needs a password; failed after + * `timeoutMs`. Scripts in the PDF are never evaluated. */ -export async function pdfText(bytes: Uint8Array): Promise> { +export async function pdfText(bytes: Uint8Array, { budget = MAX_TEXT_CHARS + TEXT_BUDGET_SLACK, timeoutMs = PDF_TIMEOUT_MS }: PdfOptions = {}): Promise> { const lib = await loader(); // pdf.js may take over the buffer: give it a copy. - const task = lib.getDocument({ data: bytes.slice() }); - try { + const task = lib.getDocument({ data: bytes.slice(), isEvalSupported: false }); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new TimedOut()), timeoutMs); }); + const read = async (): Promise> => { const doc = await task.promise; const pages = doc.numPages; - const read = Math.min(pages, MAX_PDF_PAGES); - const parts: string[] = []; - let chars = 0; - for (let n = 1; n <= read; n++) { + const last = Math.min(pages, MAX_PDF_PAGES); + const sink = new TextSink(budget); + let readable = false; + let n = 0; + while (n < last && !sink.full) { + n++; const page = await doc.getPage(n); const text = pageText((await page.getTextContent()).items); - chars += text.replace(/\s/g, '').length; - if (text) parts.push(read > 1 ? `Page ${n}\n${text}` : text); + if (text.replace(/\s/g, '').length >= MIN_PDF_CHARS_PER_PAGE) readable = true; + if (!text) continue; + if (pages > 1) sink.line(`${n > 1 ? '\n' : ''}Page ${n}`); + sink.line(text); } - if (read === 0 || chars / read < MIN_PDF_CHARS_PER_PAGE) { - return { status: 'no_text', text: '', truncated: false, pages }; - } - return { status: 'ok', text: parts.join('\n\n'), truncated: pages > read, pages }; + if (!readable) return { status: 'no_text', text: '', truncated: false, pages }; + return { status: 'ok', text: sink.text(), truncated: sink.full || n < pages, pages }; + }; + try { + return await Promise.race([read(), timeout]); } catch (err) { const name = (err as { name?: unknown } | null)?.name; if (name === 'PasswordException') return { status: 'encrypted', text: '', truncated: false }; return { status: 'failed', text: '', truncated: false }; } finally { + clearTimeout(timer); + // Also stops a read still running after the timeout. void task.destroy().catch(() => {}); } }