Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,31 +68,34 @@ 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(),
});
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) => {
Expand Down
45 changes: 40 additions & 5 deletions packages/integrations/src/pdf.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { AsyncLocalStorage } from "node:async_hooks";
import {
decodePDFRawStream,
PDFArray,
PDFCheckBox,
PDFContext,
PDFDict,
PDFDocument,
PDFDropdown,
Expand All @@ -10,6 +13,7 @@ import {
type PDFObject,
PDFOptionList,
PDFRadioGroup,
PDFRawStream,
PDFString,
PDFTextField,
rgb,
Expand All @@ -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;
Expand All @@ -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<PDFDocument> {
if (
!bytes.length ||
Expand All @@ -49,12 +78,16 @@ async function loadPdf(bytes: Uint8Array): Promise<PDFDocument> {
) {
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");
Expand All @@ -63,6 +96,8 @@ async function loadPdf(bytes: Uint8Array): Promise<PDFDocument> {
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");
Expand Down
28 changes: 28 additions & 0 deletions tests/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
32 changes: 32 additions & 0 deletions tests/pdf.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
Loading