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

  1. qpdf 11+ installed in the worker image (apt-get install qpdf).
  2. Optionally Ghostscript 10.x for full re-rendering (gs), run with -dSAFER (the default since 9.50).
  3. A worker that can run child processes with a timeout — a container job, not the upload request handler.
  4. The upload already identified as PDF by content, as in validating file signatures with libmagic.

What a PDF can contain

Risky features inside a PDF and the check that detects each A PDF can contain JavaScript actions, automatic open actions, launch actions that run programs, embedded file attachments, encryption that hides content from scanners, and malformed or huge structures that exhaust parsers. Each maps to a name in the object tree or a structural check. Features to look for, and where they live feature PDF names default action scripts /JavaScript, /JS strip or reject auto-run on open /OpenAction, /AA strip or reject launch external program /Launch reject attachments /EmbeddedFile reject or scan encryption /Encrypt reject if unreadable parser bombs huge streams, deep trees limits + timeout A plain invoice or CV uses none of the red rows; their presence alone is a strong signal.
Ordinary business PDFs almost never need scripts, launch actions or attachments.

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 --check with 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.
  • --json for inspection. Walking qpdf’s JSON output avoids writing your own PDF parser. Searching for names like /JavaScript over 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. execFile with timeout kills 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

PDF policy by who opens the file For files only the uploader sees again, validate structure and limits. For files staff or other users open, also strip or reject active content. For files published widely or fed to automated processing, always re-render with Ghostscript and serve only the rewritten output. Stricter as the audience grows only the uploader header + trailer qpdf --check size and page limits personal document storage staff or other users everything on the left reject /Launch, attachments qpdf flatten for serving job applications, support public or automated everything in the middle Ghostscript re-render serve only the output marketplaces, public docs Re-rendering costs CPU and can change appearance slightly; reserve it for files with a wide audience.
Match the cost of the check to the number of people who will open the file.

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.

PDF validation worker isolation The API stores the upload in a quarantine prefix and enqueues a job. An isolated worker with no network, a read-only filesystem and memory limits runs the header check, qpdf check and inspection, and optional flattening, then writes a report and the cleaned file. Only after a pass is the cleaned file moved to the public prefix. Parse hostile files where a crash costs nothing quarantine/ original upload isolated worker header → qpdf check → inspect → flatten no network · read-only root · 1 GB memory 20 s timeout per tool · non-root user public/ cleaned PDF report JSON The worker receives a storage key, not a path from the user, and writes to fixed output keys. A crash or timeout marks the file failed; it never leaves quarantine.
The file only reaches a public prefix after an isolated worker has produced a passing report.

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.