Sanitizing untrusted PDFs: content disarm and reconstruction
Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4
At a glance
Section titled “At a glance”A PDF that arrives from the outside world is code as much as it is a document. It can carry JavaScript, a Launch action, an embedded executable, and a structure crafted to trip a parser. Content Disarm & Reconstruction (CDR) treats that file as untrusted, keeps only what is safe, and rebuilds a clean PDF from the survivors.
This page explains how NextPDF Enterprise’s CdrEngine does that, and what the
rebuilt file is: a security projection of the original.
Why this matters
Section titled “Why this matters”The dangerous parts of a PDF are not exotic. They are standard features. The specification defines a whole catalogue of actions — what happens when a document opens, when a page is shown, when a field changes — and that catalogue includes JavaScript and Launch actions (Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4). A viewer that honours the format will happily run them. That is the attacker’s foothold: a file that is perfectly valid and perfectly malicious at the same time.
Filtering by file extension does nothing here. The threat is inside a well-formed PDF, so the only real defence is to open it, understand it, and strip the active machinery before it ever reaches a renderer. This is the input-validation posture that OWASP’s file-upload and input-validation guidance describes: never trust the bytes, and prefer rebuilding a known-good artifact over scanning a hostile one for known-bad signatures.
The short version
Section titled “The short version”- CDR assumes the input is hostile and produces a new file rather than patching the old one.
CdrEngine::sanitize()runs six phases: parse, admission control, threat detection, filter, reference scrub, rebuild.- It returns a
CdrResultthat tells you what was removed, whether the document was even admitted, and — if not — why it was rejected. - The output is a security projection. It is explicitly not an evidentiary copy, a hash match, or an archival artifact. That is a contract, not a caveat.
- This is Enterprise only. NextPDF core does not perform CDR.
How NextPDF approaches it
Section titled “How NextPDF approaches it”The engine’s own documentation states the design red line in three words:
Security Projection Layer. sanitize() is a destructive, non-reversible
transformation. It is allowed to throw bytes away. What it is not allowed to do
is pretend the result is the same document.
The pipeline is deliberately ordered. Each phase narrows trust before the next phase acts on it.
- ParsePdfReader builds the object graph from the raw bytes. A parse failure is a rejection, not a best-effort guess.
- Admission controlResource limits are checked first — object count, page count, decoded-stream size, per-stream inflation ratio. Over-budget input is rejected, never sanitised.
- DetectThreatDetector walks the graph and records each dangerous feature as a DetectedThreat with its object number and type.
- FilterObjects are partitioned into safe and removed. Catalog-level keys are stripped in place so the document catalog itself survives.
- Reference scrubPointers to removed objects are cleaned so the rebuilt file has no dangling references.
- RebuildCdrRebuilder serialises only the safe objects into a new PDF. The output is fresh structure, not an edited original.
Admission before disarm. Before a single threat is removed, the engine
asks whether the document is even worth processing. CdrPolicy carries the
limits — maxObjects, maxPageCount, maxDecodedStreamBytes, and a
maxInflationRatio that defends against decompression bombs. A document that
blows past them is rejected, and the CdrResult says so with
admitted: false and a rejectionReason. This distinction is load-bearing:
“we cleaned it” and “we refused it” are different outcomes, and the result type
keeps them apart so your error reporting can too.
Threats are named, not guessed. ThreatDetector::detect() maps each
dangerous construct to a ThreatType — JavaScript, LaunchAction,
OpenAction, AdditionalActions, RemoteGoTo, SubmitForm, ImportData,
EmbeddedFiles, RichMedia, NamedJavaScript, and more — each tied to a
specific PDF feature. An object that cannot be parsed at all becomes its own
threat type, UnparseableObject, because an object the sanitiser cannot read
is an object it cannot vouch for. Every finding is a DetectedThreat carrying
the offending object number, so removal is precise.
Removal protects the document, not just the payload. Some dangerous keys
live on the document catalog (the Root) — an /OpenAction that fires on open,
for instance. Removing the whole Root object to kill one key would destroy the
catalog and silently break the file. The engine handles these as in-place key
strips on the catalog instead, and as a fail-closed guard it refuses to emit a
rebuilt document that has lost its /Root at all. A sanitiser that produces a
structurally broken file while reporting success is exactly the failure mode
this guard exists to prevent.
Practical example
Section titled “Practical example”The shape below is the real entry point. You hand the engine raw bytes and a
policy; you get back a CdrResult that is honest about what happened.
<?php
declare(strict_types=1);
use NextPDF\Enterprise\Security\Cdr\CdrEngine;use NextPDF\Enterprise\Security\Cdr\CdrPolicy;
$engine = new CdrEngine();
// Standard policy removes the known active threats — JavaScript, Launch// actions, remote go-to, form submit/import, and the rest — while leaving// the lossy opt-in "Strip*" cases off by default.$result = $engine->sanitize($untrustedPdfBytes, CdrPolicy::standard());
if (!$result->admitted) { // Rejected by admission control (e.g. object/page limit, zip bomb). // This is NOT a sanitised document. Do not serve it; report the reason. throw new \RuntimeException($result->rejectionReason);}
if ($result->hadThreats()) { // The disarmed bytes are safe to render. Each removed threat carries its // type and object number for your audit log — never silently. foreach ($result->removedThreats as $threat) { error_log(\sprintf( 'CDR removed %s in object %d', $threat->type->value, $threat->objectNumber, )); }}
$cleanBytes = $result->sanitizedPdf; // The security projection. Not the original.There is no path where this code quietly hands back a still-dangerous file.
Either the document is admitted and disarmed, or it is rejected with a stated
reason. The removedThreats list means the disarm is auditable, not magical.
Common misconception
Section titled “Common misconception”“CDR is just redaction with extra steps.”
It is not, and conflating the two is dangerous. Redaction removes information — names, account numbers, the content a human must not see. CDR removes capability — the JavaScript, the Launch action, the embedded payload that a machine must not run. They have opposite success criteria. A redaction is correct when sensitive content is gone and the rest is preserved verbatim. A disarm is correct when the threat is gone, and it is perfectly willing to alter benign structure to get there. Use the tool that matches your intent; do not reach for one expecting the guarantees of the other.
A second misconception is that a clean rebuild proves the original was clean. It proves nothing about the original. It proves only that the output contains no detected threat. The input may have been a weapon; CDR’s job is to make sure what you forward downstream is not.
Limits and boundaries
Section titled “Limits and boundaries”- The output is a security projection, not an evidentiary copy. The
CdrEnginesource carries this as an architectural red line. The sanitised PDF must not be used for legal evidence preservation, for hash comparison against the original, or as an archival copy. The transformation is destructive and non-reversible by design. - Detection has scope. CDR removes the threats it knows how to name. It is a strong, auditable layer in a defence-in-depth stack. Keep it behind the same upload validation, content-type checks, and least-privilege handling that OWASP’s file-upload guidance describes.
- Some policies are intentionally lossy. The opt-in
Strip*cases remove embedded files, signatures, form fields, layers, and 3D media. Those are powerful and they will delete legitimate content — a ZUGFeRD/Factur-X invoice payload, say. They are off by default for exactly that reason. Turn them on knowingly. - Signatures do not survive a rebuild. Reconstructing the file changes its bytes, so any original digital signature no longer matches its byte range. A disarmed document is unsigned with respect to the source. If you need a signed artifact, sign the clean output as a new act.
| Edition | Availability |
|---|---|
| Core | Not available. NextPDF core does not perform CDR. It parses, renders, and writes PDFs; it does not threat-detect or rebuild untrusted input. |
| Pro | Not available in the Pro edition. |
| Enterprise | Available via |
Related docs
Section titled “Related docs”- How PDF encryption really works — the other half of handling sensitive PDFs: protecting content versus removing capability.
- Errors as a feature — the fail-closed
philosophy that CDR’s admission-control rejections and
/Rootguard embody. - An API that refuses to guess — why a result type that distinguishes cleaned from rejected beats a silent best effort.
Glossary
Section titled “Glossary”- CDR (Content Disarm & Reconstruction) — a sanitisation strategy that parses an untrusted file, removes active or dangerous components, and rebuilds a clean file from the safe remainder, rather than scanning for known-bad signatures.
- Security projection — a sanitised output that preserves enough of the source to be useful while guaranteeing the removal of threats. It is deliberately not byte-faithful and not suitable for evidence, hashing, or archival.
- Admission control — the pre-sanitisation gate that rejects documents exceeding resource limits (object count, page count, decoded-stream size, inflation ratio) before any disarm work begins.
- Action — a PDF construct that makes something happen on a trigger such as document-open or a field change; the action-type catalogue (Spec: ISO 32000-2, §12.6.4ISO 32000-2 §12.6.4) includes JavaScript and Launch actions, the canonical CDR threat surface.
- Dangling reference — a pointer to an object that no longer exists after filtering. The reference-scrub phase removes these so the rebuilt file stays structurally consistent.