diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 500a18a5d..20008d2be 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -68,17 +68,20 @@ export interface BrowserSession { previewUrl?: string; consoleUrl?: string; } +// Invisible characters that reorder or hide text without showing on the review card. Joiners and +// directional marks stay because emoji sequences, Persian and Arabic text depend on them. +const hiddenText = /[\u200B\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\u{E0000}-\u{E007F}]/gu; +const visibleText = () => z.string().overwrite((text) => text.replace(hiddenText, "")); export const emailDraftSchema = z.object({ to: z.array(z.email()).min(1).max(50), cc: z.array(z.email()).max(50).default([]), bcc: z.array(z.email()).max(50).default([]), - subject: z - .string() + subject: visibleText() .trim() .min(1) .max(998) .refine((s) => !/[\r\n]/.test(s), "Subject must be a single line"), - body: z.string().min(1).max(100000), + body: visibleText().min(1).max(100000), attachmentIds: z.array(z.string()).max(10).default([]), threadId: z.string().optional(), replyToMessageId: z.string().optional(), @@ -86,13 +89,13 @@ export const emailDraftSchema = z.object({ export const eventDraftSchema = z .object({ calendarId: z.string().default("primary"), - title: z.string().trim().min(1).max(500), + title: visibleText().trim().min(1).max(500), start: z.string().min(1), end: z.string().min(1), allDay: z.boolean().default(false), timeZone: z.string().default("America/Los_Angeles"), - location: z.string().max(2000).default(""), - description: z.string().max(10000).default(""), + location: visibleText().max(2000).default(""), + description: visibleText().max(10000).default(""), attendees: z.array(z.email()).max(50).default([]), }) .superRefine((value, ctx) => { diff --git a/packages/integrations/src/pdf.ts b/packages/integrations/src/pdf.ts index a20430c1e..385318873 100644 --- a/packages/integrations/src/pdf.ts +++ b/packages/integrations/src/pdf.ts @@ -1,6 +1,9 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { + decodePDFRawStream, PDFArray, PDFCheckBox, + PDFContext, PDFDict, PDFDocument, PDFDropdown, @@ -10,6 +13,7 @@ import { type PDFObject, PDFOptionList, PDFRadioGroup, + PDFRawStream, PDFString, PDFTextField, rgb, @@ -18,6 +22,7 @@ import { const MAX_PDF_BYTES = 10 * 1024 * 1024; const MAX_PDF_PAGES = 500; +const MAX_DECODED_BYTES = 10 * MAX_PDF_BYTES; export class PdfError extends Error { readonly status = 422; @@ -41,6 +46,30 @@ export interface PdfInspection { fields: { name: string; value: string; type: "text" | "checkbox" | "unsupported" }[]; } +// pdf-lib decompresses object and cross-reference streams while loading, with no output limit, +// so a file under MAX_PDF_BYTES could still expand without bound. Every pdf-lib decoder grows its +// output buffer through DecodeStream.ensureBuffer, so cap what one load may allocate there. +const decodeBudget = new AsyncLocalStorage<{ remaining: number }>(); +let decodeStream = Object.getPrototypeOf( + decodePDFRawStream( + PDFRawStream.of(PDFContext.create().obj({ Filter: "ASCIIHexDecode" }), new Uint8Array()), + ), +); +while (decodeStream && !Object.hasOwn(decodeStream, "ensureBuffer")) + decodeStream = Object.getPrototypeOf(decodeStream); +if (!decodeStream) throw new Error("pdf-lib no longer decodes through DecodeStream"); +const ensureBuffer = decodeStream.ensureBuffer; +decodeStream.ensureBuffer = function (this: { buffer: Uint8Array }, requested: number) { + const before = this.buffer.byteLength; + const buffer: Uint8Array = ensureBuffer.call(this, requested); + const budget = decodeBudget.getStore(); + if (budget) { + budget.remaining -= buffer.byteLength - before; + if (budget.remaining < 0) throw new PdfError("PDF content expands beyond the supported size"); + } + return buffer; +}; + async function loadPdf(bytes: Uint8Array): Promise { if ( !bytes.length || @@ -49,12 +78,16 @@ async function loadPdf(bytes: Uint8Array): Promise { ) { throw new PdfError("Invalid PDF: expected nonempty PDF bytes up to 10 MiB"); } + const budget = { remaining: MAX_DECODED_BYTES }; try { - const doc = await PDFDocument.load(new Uint8Array(bytes), { - ignoreEncryption: false, - throwOnInvalidObject: true, - updateMetadata: false, - }); + const doc = await decodeBudget.run(budget, () => + PDFDocument.load(new Uint8Array(bytes), { + ignoreEncryption: false, + throwOnInvalidObject: true, + updateMetadata: false, + }), + ); + if (budget.remaining < 0) throw new PdfError("PDF content expands beyond the supported size"); if (doc.isEncrypted) throw new PdfError("Encrypted PDFs are not supported"); if (doc.catalog.lookupMaybe(PDFName.of("AcroForm"), PDFDict)?.has(PDFName.of("XFA"))) { throw new PdfError("Unsupported XFA PDF form"); @@ -63,6 +96,8 @@ async function loadPdf(bytes: Uint8Array): Promise { throw new PdfError("PDF must contain between 1 and 500 pages"); return doc; } catch (error) { + // pdf-lib's parser can catch the limit error, then succeed or fail with its own error. + if (budget.remaining < 0) throw new PdfError("PDF content expands beyond the supported size"); if (error instanceof PdfError) throw error; if (error instanceof Error && /encrypt/i.test(error.message)) throw new PdfError("Encrypted PDFs are not supported"); diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 7c78849d8..ac2d22e4b 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -163,3 +163,31 @@ test("proposal schema discriminates on kind and validates nested data", () => { ); assert.equal(proposalSchema.safeParse({ kind: "unknown.kind", data: {} }).success, false); }); + +test("drafts drop invisible characters so the review card shows what is sent", () => { + const hidden = "\u200B\u202E\u2066\u2069\u2060\uFEFF\u{E0041}"; + const draft = emailDraftSchema.parse({ + ...email, + subject: `Your${hidden} visit`, + body: `See you${hidden} soon.`, + }); + assert.equal(draft.subject, "Your visit"); + assert.equal(draft.body, "See you soon."); + const created = eventDraftSchema.parse({ + ...event, + title: `Museum${hidden} visit`, + location: `Hall${hidden} A`, + description: `Bring${hidden} tickets`, + }); + assert.deepEqual( + [created.title, created.location, created.description], + ["Museum visit", "Hall A", "Bring tickets"], + ); + // A subject made only of invisible characters is empty once they are dropped. + assert.equal(emailDraftSchema.safeParse({ ...email, subject: hidden }).success, false); +}); + +test("drafts keep joiners and directional marks that real text uses", () => { + const body = "Family 👨\u200D👩\u200D👧, می\u200Cخواهم, \u200Fשלום\u200E"; + assert.equal(emailDraftSchema.parse({ ...email, body }).body, body); +}); diff --git a/tests/pdf.test.ts b/tests/pdf.test.ts index 50bfb2591..f81a6413c 100644 --- a/tests/pdf.test.ts +++ b/tests/pdf.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { deflateSync } from "node:zlib"; import { PDFDocument, PDFName, PDFString } from "pdf-lib"; import { createSamplePdf, @@ -152,3 +153,34 @@ test("PDF failures expose safe typed 422 errors including malformed field and fo (error: unknown) => error instanceof PdfError && /malformed|inspect|field/i.test(error.message), ); }); + +test("PDFs whose compressed content expands past the decode limit are rejected", async () => { + // A valid one-page PDF plus an object stream that decompresses to just over 100 MiB. + const content = deflateSync( + Buffer.concat([ + Buffer.from("5 0 "), + Buffer.alloc(100 * 1024 * 1024, 0x20), + Buffer.from("null"), + ]), + ); + const oversized = Buffer.concat([ + Buffer.from( + "%PDF-1.5\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>\nendobj\n" + + `4 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /Length ${content.length} >>\nstream\n`, + ), + content, + Buffer.from("\nendstream\nendobj\ntrailer\n<< /Root 1 0 R /Size 5 >>\n%%EOF\n"), + ]); + assert.ok(oversized.length < 10 * 1024 * 1024); + const [rejected, sample] = await Promise.allSettled([ + inspectPdf(oversized), + createSamplePdf().then(inspectPdf), + ]); + assert.equal(rejected.status, "rejected"); + assert.ok(rejected.reason instanceof PdfError); + assert.match(rejected.reason.message, /expands beyond the supported size/); + // The limit is per load: a concurrent ordinary PDF is unaffected. + assert.equal(sample.status === "fulfilled" && sample.value.pageCount, 2); +});