fix(pdf): stop page tools failing on short and encrypted PDFs (#594)

Empty the hardcoded page-range default in remove/split/extract PDF tools (remove-pages defaulted to "2,4-6", out of range for any PDF under 6 pages) and disable submit until a range is entered. Reject password-protected PDFs up front for PDF-only tools with guidance to unlock first, instead of failing cryptically in the qpdf worker. Adds integration + e2e coverage.
This commit is contained in:
SnapOtter
2026-07-21 06:14:43 +00:00
committed by GitHub
parent 4ba7503f15
commit 73df107758
11 changed files with 173 additions and 9 deletions
+3 -1
View File
@@ -43,7 +43,9 @@ export async function validatePdfPath(
opts.signal?.throwIfAborted();
if (passwordProtected) {
if (opts.rejectPasswordProtected) {
throw new InputValidationError("Password-protected PDFs cannot be processed by this tool");
throw new InputValidationError(
"This PDF is password-protected. Unlock it first with the Unlock PDF tool, then try again.",
);
}
// Without a password qpdf cannot safely inspect the structure or pages.
return;
+17
View File
@@ -118,6 +118,14 @@ export interface ToolRouteConfig<T> {
* tools that intentionally accept damaged inputs (e.g. repair-pdf).
*/
skipStructuralValidation?: boolean;
/**
* When set, the factory does NOT reject password-protected PDFs at input
* validation. Only unlock-pdf sets this: it takes an encrypted PDF plus a
* password and decrypts it. Every other document tool leaves this off, so
* the factory rejects encrypted PDFs up front (400) with guidance to unlock
* first, instead of letting qpdf fail cryptically in the worker.
*/
allowPasswordProtectedPdf?: boolean;
/**
* When set, produces a redacted copy of settings for the durable DB
* row. Passwords and other secrets are replaced so they do not persist
@@ -398,6 +406,15 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const prepared = await handlerForPosition(i).prepare(fileBuffer, fname, {
scratchDir,
lenient: config.skipStructuralValidation,
// Reject encrypted PDFs up front only for PDF-only tools (qpdf
// page ops etc.). Scoped to acceptedInputs === [".pdf"] so the
// flag never forces a %PDF- header on non-PDF document tools
// (markdown/epub/docx converters). unlock-pdf opts out.
rejectPasswordProtected:
modality === "document" &&
!config.allowPasswordProtectedPdf &&
!!accepted?.length &&
accepted.every((e) => e === ".pdf"),
});
fileBuffer = prepared.buffer;
fname = prepared.filename;
+7 -1
View File
@@ -123,7 +123,13 @@ export function registerSignPdf(app: FastifyInstance) {
const pdfBuffer = await getObjectBuffer(pdfKey);
try {
await inputHandlerFor("document").prepare(pdfBuffer, filename, { scratchDir: tmpdir() });
// Signing needs a readable PDF; reject encrypted ones up front (the
// "unlock first" guidance rides in details) with the same policy the
// factory gives other PDF-only tools, instead of failing in the worker.
await inputHandlerFor("document").prepare(pdfBuffer, filename, {
scratchDir: tmpdir(),
rejectPasswordProtected: true,
});
} catch (err) {
return reply.status(400).send({
error: "Invalid PDF",
+4
View File
@@ -13,6 +13,10 @@ export function registerUnlockPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "unlock-pdf",
settingsSchema,
// unlock-pdf's whole job is to decrypt: its input is an encrypted PDF plus
// the password, so it must opt out of the factory's password-protected
// rejection that every other document tool gets by default.
allowPasswordProtectedPdf: true,
redactSettingsForAudit: (settings) => {
const s = settings as z.infer<typeof settingsSchema>;
return {