Technical Article

Secure PDF Preview in Delphi Applications with PDFium Component

A service-desk team I supported previewed customer attachments in an embedded PDF pane. One crafted "invoice" carried a link whose visible text said https://portal.example.com but whose action pointed at a file:// URI on an attacker-controlled UNC share — the kind of destination Windows resolves by volunteering NTLM credentials before any browser even opens. The pane dutifully shelled out on click. No exploit, no malformed file, no engine bug: just a viewer doing default things with hostile input. A preview pane inside a line-of-business application is an execution decision, and PDFium Component — a source-code PDF viewer for Delphi, C++Builder, and Lazarus — puts the policy hooks for that decision in your hands: load-time switches, a link interception event, attachment access calls, and permission queries. This article walks the attack surface in the order a document reaches it.

The threat model of a preview pane

Be honest about what "secure preview" means. The renderer itself parses untrusted bytes, and the engine's hardening is the floor you stand on — but everything above that floor is application policy: whether scripts initialize, what happens when a user clicks a link, whether embedded files can reach disk, whether clipboard and printer are doors or walls. One scoping note up front: the engine's FPDF_SetSandBoxPolicy switch has minimal practical effect because most engine restrictions are built in, so budget none of your isolation story to it. For genuinely hostile input streams — a public upload portal, say — real isolation means rendering in a separate low-privilege process; in-process flags are policy, not containment.

Two surfaces are easy to forget because no click ever touches them. Temporary files: if your pipeline stages inbound documents to disk before preview, those staged copies outlive the session unless something verifiably deletes them, and "recoverable from the temp directory" defeats every control the pane itself enforces — prefer loading from memory through TPdfStreamAdapter so the hostile bytes never get a path of their own. And the clipboard: a preview that allows select-and-copy has already exported the document, one screenful at a time.

Kill JavaScript at load time, not in the UI

Document JavaScript in PDFium Component initializes only together with the form-fill environment. Loading with FormFill := False therefore disables scripting at the root instead of suppressing its symptoms:

procedure TPreviewPane.LoadUntrusted(const FilePath: string);
begin
  Pdf.FileName := FilePath;
  Pdf.FormFill := False;     // no form environment, hence no JavaScript engine
  Pdf.Active := True;

  FPermissions := Pdf.Permissions;   // raw flag word; all bits set = unrestricted
end;

The trade-off is real and belongs in your spec: with form fill disabled, legitimate AcroForm interaction and validation scripts are gone too. Fields render with their last saved appearance but cannot be edited. For a preview pane that is usually correct — preview means look, not fill — but if the same window doubles as a form-filling surface for trusted internal documents, build two load paths with an explicit trust decision between them, not one path with a compromise setting. The form-filling side of that split has its own traps, covered in form field navigation and appearance regeneration.

Links: the default handler shells out

Unhandled link clicks go straight to the operating system — the viewer's default LinkOptions include loAutoOpenURI, which is exactly the NTLM story above. Two events form the choke point: OnWebLinkClick for URLs detected in page text, and OnAnnotationLinkClick for link annotations carrying URI or launch actions. In both, set Handled := True unconditionally, then re-allow only what policy permits — and, as defense in depth, drop loAutoOpenURI from LinkOptions for hostile input and make sure loAutoLaunch (off by default) never creeps in:

procedure TPreviewPane.PdfViewWebLinkClick(Sender: TObject;
  const Url: WString; var Handled: Boolean);
begin
  Handled := True;   // never fall through to the default shell behavior

  if (AnsiStartsText('https://', Url) or AnsiStartsText('http://', Url))
    and HostIsAllowed(Url) then
    OpenInBrowser(Url)
  else
    FAudit.LogBlockedLink(FDocumentId, Url);
end;

Two implementation notes. Scheme checks must be prefix checks on the raw string before any parsing, because file://, UNC paths, and exotic schemes are precisely the values that crash naive URL parsers or slip past them. And log every block with the document identity attached — a burst of blocked file:// links across many inbound documents is an incident signal your security team wants, not noise.

Attachments: extension policy and the filename you didn't choose

A PDF is a container, and AttachmentCount plus the AttachmentName[] property tell you what it carries before anything touches disk. Two separate controls matter. The obvious one is type policy — an allowlist of extensions that may ever be exported. The subtle one is that the attachment's name is attacker-controlled data: an embedded name like ..\..\Startup\update.exe turns a careless save into a path traversal. The component hands you the payload as bytes through Attachment[] — your code chooses the path, so build it from a sanitized basename, never from the raw embedded string:

procedure TPreviewPane.ExportAttachment(Index: Integer; const TargetDir: string);
var
  RawName, SafeName, Ext: string;
  Data: TBytes;
begin
  RawName := string(Pdf.AttachmentName[Index]);
  SafeName := ExtractFileName(RawName);    // strips any path components
  Ext := LowerCase(ExtractFileExt(SafeName));

  if not FAllowedExt.Contains(Ext) then    // allowlist, not blocklist
    raise EPreviewPolicy.CreateFmt('Attachment type %s blocked by policy', [Ext]);

  Data := Pdf.Attachment[Index];           // embedded payload as raw bytes
  TFile.WriteAllBytes(
    IncludeTrailingPathDelimiter(TargetDir) + SafeName, Data);
end;

Prefer the allowlist direction. A blocklist of "dangerous" extensions is a race you lose the day someone weaponizes an extension you never heard of; an allowlist of .pdf, .png, and .csv fails closed.

What encryption permissions actually promise

The standard security handler of ISO 32000-1 encodes permission flags — printing, content copying, modification — that the Permissions and UserPermissions properties surface as raw bitmasks once the document opens (ISO 32000-1 Table 22 defines the bits; an unencrypted file reports all bits set). Read them and honor them in your command layer, but understand their nature: for a document encrypted with an owner password and an empty user password, the content decrypts fully on open, and the flags are a request to viewers rather than an enforcement mechanism. The corollary cuts both ways. Do not present permission flags to users as a security feature of the documents they send; and conversely, honor the accessibility-extraction bit (bit 10) even where general copying (bit 5) is denied — screen-reader access is carved out separately in the permission model for good reason.

Enforce denied actions at the command level, not by hiding toolbar buttons. Ctrl+C, context menus, and drag-select all bypass a toolbar; a single permission check inside the copy command bypasses nothing.

For documents that do require a user password, assign Password before Active := True and treat the value like the secret it is: fetch it from your credential store per session, keep it out of logs and crash reports, and never persist it next to the document. A preview pane that caches passwords "for convenience" has quietly become a password database with none of the protections of one.

Printing deserves its own decision rather than inheriting the copy rule. A physical printout is unaudited by definition, but blocking print outright pushes users toward screenshots, which are worse. Many teams land on "print allowed, watermarked with user identity and timestamp" — enforce that inside the print command, and remember a watermark is deterrence and attribution, never prevention.

What intake should have told you already

A preview pane makes better decisions when the file arrives with a dossier: encrypted or not, JavaScript present, attachment census, form type. That inspection pass belongs upstream of the viewer — the pattern in building a PDF intake review workbench produces exactly the flags a preview policy consumes. Files intake marked risky can then open through the hardened path automatically, while routine documents keep their conveniences. Tie the two together with one shared policy object rather than two configuration screens that will drift apart by the second release.

Frequently asked questions

How do I stop a PDF from running JavaScript in my Delphi viewer?

Load it with FormFill := False before Active := True; the scripting environment never initializes. The cost: AcroForm fields are read-only for that session.

Are PDF permission flags enough to prevent copying or printing?

No. For owner-password-only documents the flags are advisory; enforcement happens in your command layer. Treat the Permissions bitmask as input to your policy, not as the policy.

Is blocking dangerous attachment extensions sufficient?

Use an allowlist instead of a blocklist, sanitize the embedded filename with ExtractFileName before any save, and write exports only into a directory that no search path or autostart mechanism reads.

Do I need a separate process to preview untrusted PDFs safely?

For ordinary business intake, in-process preview with scripting disabled and links intercepted is a reasonable bar. For anonymous public uploads, render in a separate low-privilege worker process and ship bitmaps to the UI — an engine flaw then costs you a worker, not the application.

Licensing, the security-related API surface, and a hardened-viewer demo are on the product page: PDFium Component.