Validating PDF Uploads Safely
Validate an uploaded PDF in four passes: confirm the %PDF- header and %%EOF trailer, run qpdf --check in a sandboxed worker with a timeout and memory limit to prove the structure parses, inspect the object tree for /JavaScript, /JS, /OpenAction, /Launch, /EmbeddedFile and /Encrypt, and enforce limits on page count and decompressed size. Reject or quarantine files that fail, and for high-risk flows (files other users will open) flatten the PDF by re-rendering it — qpdf to strip active content, or Ghostscript to rewrite it entirely — so what you serve is a document you produced rather than one an attacker crafted.
PDF is a container format with a programming language, form actions, file attachments and a long history of reader exploits. Most uploaded PDFs are invoices and CVs, but the same upload field accepts a file that launches JavaScript when opened, carries an executable attachment, or is built to crash or exhaust the parser that generates your thumbnails. This page is part of server-side file validation in backend validation and cloud storage architecture; pair it with virus scanning, which catches known malware but not structural abuse.
When to use this approach
- Users upload PDFs that other users, staff or automated systems will open.
- You generate thumbnails, extract text or run OCR on uploaded PDFs.
- Compliance requires that shared documents contain no active content.
Prerequisites
qpdf11+ installed in the worker image (apt-get install qpdf).- Optionally Ghostscript 10.x for full re-rendering (
gs), run with-dSAFER(the default since 9.50). - A worker that can run child processes with a timeout — a container job, not the upload request handler.
- The upload already identified as PDF by content, as in validating file signatures with libmagic.
What a PDF can contain
Implementation
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { open, stat } from "node:fs/promises";
const run = promisify(execFile);
const LIMITS = { maxBytes: 50 * 1024 * 1024, maxPages: 500, timeoutMs: 20_000 };
const RISKY = ["/JavaScript", "/JS", "/OpenAction", "/AA", "/Launch", "/EmbeddedFile", "/RichMedia", "/SubmitForm"];
export interface PdfReport { ok: boolean; reasons: string[]; pages?: number; risky: string[]; encrypted: boolean }
async function headerAndTrailer(path: string): Promise<boolean> {
const fh = await open(path, "r");
try {
const { size } = await fh.stat();
const head = Buffer.alloc(1024); await fh.read(head, 0, 1024, 0);
const tailLen = Math.min(2048, size);
const tail = Buffer.alloc(tailLen); await fh.read(tail, 0, tailLen, size - tailLen);
return head.indexOf("%PDF-") !== -1 && tail.indexOf("%%EOF") !== -1;
} finally { await fh.close(); }
}
export async function validatePdf(path: string): Promise<PdfReport> {
const reasons: string[] = [];
const { size } = await stat(path);
if (size > LIMITS.maxBytes) return { ok: false, reasons: ["larger than 50 MB"], risky: [], encrypted: false };
if (!(await headerAndTrailer(path))) return { ok: false, reasons: ["missing %PDF header or %%EOF"], risky: [], encrypted: false };
const opts = { timeout: LIMITS.timeoutMs, maxBuffer: 64 * 1024 * 1024 };
// 1. Structural check. Exit 0 = clean, 3 = warnings (repaired), 2 = errors.
try { await run("qpdf", ["--check", path], opts); }
catch (e: any) {
if (e.killed) return { ok: false, reasons: ["parser timed out"], risky: [], encrypted: false };
if (e.code !== 3) return { ok: false, reasons: ["structure is damaged"], risky: [], encrypted: false };
reasons.push("minor structural warnings");
}
// 2. Object tree as JSON: page count, encryption, risky names.
const { stdout } = await run("qpdf", ["--json", "--json-key=pages", "--json-key=encrypt", "--json-key=objects", path], opts);
const json = JSON.parse(stdout);
const pages = json.pages?.length ?? 0;
const encrypted = Boolean(json.encrypt?.encrypted);
const text = JSON.stringify(json.objects ?? {});
const risky = RISKY.filter((name) => text.includes(`"${name}"`));
if (pages === 0) reasons.push("no pages");
if (pages > LIMITS.maxPages) reasons.push(`more than ${LIMITS.maxPages} pages`);
if (encrypted && json.encrypt?.userpasswordmatched === false) reasons.push("password protected");
if (risky.includes("/Launch")) reasons.push("contains a launch action");
const blocking = reasons.filter((r) => r !== "minor structural warnings");
return { ok: blocking.length === 0, reasons, pages, risky, encrypted };
}
/** Rewrite the file without active content. Output is a new file you control. */
export async function flattenPdf(input: string, output: string): Promise<void> {
await run("qpdf", [
"--remove-restrictions", "--decrypt",
"--remove-attachments" , // qpdf 11.9+; drops /EmbeddedFiles
"--flatten-annotations=all",
"--generate-appearances",
"--object-streams=generate",
input, output,
], { timeout: LIMITS.timeoutMs });
}
For a harder guarantee, re-render with Ghostscript, which reinterprets the page content and writes a brand-new PDF with no scripts, actions or attachments:
gs -dSAFER -dBATCH -dNOPAUSE -dQUIET -sDEVICE=pdfwrite \
-dCompatibilityLevel=1.7 -dPDFSETTINGS=/prepress \
-dNOOUTERSAVE -dMaxBitmap=500000000 \
-sOutputFile=/work/out.pdf /work/in.pdf
Line-by-line on the decisions that matter
- Header and trailer first. Reading 1 KB from each end is nearly free and rejects renamed files, truncated uploads and polyglots that are really something else before any parser runs. The header may legitimately appear after a little junk, which is why the check searches the first kilobyte instead of requiring offset zero.
qpdf --checkwith exit codes. qpdf is a structural tool, not a renderer, so it is much less exposed than a viewer, and it distinguishes damaged files (exit 2) from ones it can repair (exit 3). Accepting warnings avoids rejecting the many slightly broken PDFs real software produces.--jsonfor inspection. Walking qpdf’s JSON output avoids writing your own PDF parser. Searching for names like/JavaScriptover the object dump is crude but effective, because qpdf has already decompressed the object streams those names usually hide in.- Timeouts on every call. PDF parser bombs — deeply nested objects, cross-reference loops, streams that decompress to gigabytes — are designed to hang workers.
execFilewithtimeoutkills the child; the worker’s container memory limit catches the rest. - Flattening as an output, not a check. Stripping features is a transform: the served file is a new one. Keep the report, keep the original in quarantine if you must, and point downloads at the flattened copy.
Choosing a policy
Running parsers in a sandbox
Every tool in this pipeline parses attacker-controlled input, and every PDF library has had memory-safety bugs. Run validation in a dedicated worker, not the API process: a container with no network access, a read-only root filesystem, a small writable scratch directory, a non-root user, and CPU and memory limits. Cloud Run jobs, ECS tasks with networkMode: none-style isolation, or a Kubernetes job with a restrictive security context all work. The upload service passes a storage key; the worker downloads the file, validates, writes a report and the flattened output, and exits.
Ghostscript deserves extra care. It is a full PostScript interpreter; -dSAFER restricts file access and has been the default since 9.50, but critical sandbox escapes have still been published against it. Keep it patched, keep it in the network-less container, and never pass it user-controlled command-line options. If you only need thumbnails, a renderer such as pdftoppm from Poppler — in the same sandbox — has a smaller surface than a full PDF rewrite.
Communicating results to users
Validation failures on PDFs are confusing to users, because the file opens fine on their machine. Translate the report into something actionable. “This PDF is password protected — remove the password and upload again” and “This PDF contains an attached file, which we cannot accept” are fixable; “invalid file” is not. For structural damage, suggest re-exporting from the original application or using “Print to PDF”, which produces a clean file in nearly every case.
When you flatten silently, say so where it matters. Forms lose their fields when annotations are flattened, and a user who uploaded a fillable form expecting others to fill it in will be surprised. If fillable forms are part of your product, keep form fields (--flatten-annotations=screen rather than all) and strip only the scripts and actions.
Configuration gotchas
qpdf --check passes but a viewer shows a blank page. Structural validity says nothing about content. If you need assurance the document renders, render page one to an image with pdftoppm -r 50 -f 1 -l 1 and check that it is not empty.
Exit code 3 on nearly every file from one scanner vendor. Many scanners write slightly incorrect cross-reference tables. Treat exit 3 as a pass with a note, and rewrite the file with qpdf so downstream tools see a clean version.
Encrypted PDFs with an empty user password. Many “protected” PDFs only restrict printing or copying and open without a password. qpdf reports userpasswordmatched: true; they are readable and can be decrypted for inspection.
--json output is huge. For scanned documents with thousands of images, restrict keys (as above) and raise maxBuffer, or stream the output to a file and search it there.
Verification
# A benign PDF passes; one with an OpenAction script is flagged.
node -e 'import("./pdf.js").then(async m => console.log(await m.validatePdf("fixtures/invoice.pdf")))'
node -e 'import("./pdf.js").then(async m => console.log(await m.validatePdf("fixtures/openaction-js.pdf")))'
# { ok: true, reasons: [], pages: 2, risky: [ '/OpenAction', '/JavaScript', '/JS' ], encrypted: false }
# After flattening, the risky names are gone.
qpdf --json --json-key=objects out.pdf | grep -cE '"/(JavaScript|JS|OpenAction|Launch|EmbeddedFile)"' # 0
Frequently Asked Questions
Does antivirus scanning make PDF validation unnecessary?
No. Antivirus catches known malicious samples by signature; structural validation catches new or targeted files by what they contain. They overlap but neither replaces the other, and structural checks also protect your own parsers.
Should I reject every PDF with JavaScript?
For most products, yes — or strip it by flattening. Legitimate uses (calculating forms) are rare in uploads, and when they exist the audience is usually known and small.
Is it safe to generate thumbnails from uploaded PDFs?
Yes, if the renderer runs in the isolated worker with limits and a timeout, and only after the structural check passes. Never render inside the request handler.