← dompdf.js Studio

PDF Generation Security Best Practices

Security is the easiest thing to skip in frontend PDF generation, because the feature looks innocuous: take some HTML, produce a PDF, let the user download it. But the pipeline from HTML to PDF is full of attack surface. dompdf.js accepts HTML strings or DOM elements in addPage, which means any user-controlled content that gets concatenated into a template is parsed, rendered, and embedded into the final file — opening the door to XSS, phishing content injection, and abuse of external resource loading. The mitigations, fortunately, are mature and well understood: sanitize input with DOMPurify, constrain the page with a strict Content-Security-Policy, validate every external URL, minimize sensitive data before it enters a PDF, and use encryption and permission controls on the output file. This article walks through the attack surface systematically. It starts with the security model of frontend PDF generation and why input sanitization becomes your responsibility, then dissects the three classic injection scenarios and the correct way to use DOMPurify, moves on to CSP and external resource policy including supply-chain defenses like Subresource Integrity, covers sensitive data protection and PDF encryption with permission control, and closes with a concrete security checklist and the common misconceptions that get teams into trouble. Each section pairs the threat with the concrete defense and the code that implements it, so the guide works as both explanation and copyable reference, and the checklist at the end consolidates everything into a single pass for any release that touches the export path. The misconceptions section alone is worth reading, because these three errors account for most real-world PDF injection incidents. Security here is not a release-time patch but a set of constraints designed into the template pipeline from the first line; this guide gives you the checklist to make that happen.

The Security Model of Frontend PDF Generation

dompdf.js runs in the browser, takes HTML as input, and emits a PDF file, and the security boundary of that model differs from ordinary page rendering. Attack-controlled strings that enter a template are parsed as structure: scripts, links, and image references may be executed or embedded, and because this is a pure frontend pipeline there is no server-side filter standing between the input and the output. Every place where you concatenate dynamic data into a template is a potential injection point, and the sanitization duty falls entirely on the frontend.

A clear threat model is the starting point for security work. Your PDFs may contain free-text user input like names, addresses, and comments; business data like order IDs and amounts; and user-supplied image URLs. These carry different risk levels — free text needs sanitization, business data needs tamper resistance, and external URLs need source validation. Classifying inputs by origin and trust level lets you apply proportionate defenses instead of a blunt ban on functionality.

The output side is an easy boundary to forget: once generated, the PDF leaves the browser — downloaded, forwarded, stored — and you lose control over its contents. That means sensitive data should either never enter the PDF or be protected by encryption and permissions at generation time. Ask the design-stage questions early: what sensitive information does this PDF contain, who is allowed to view it, and what are the consequences of leakage? The answers determine how much security investment is warranted.

A useful mental model is to treat every PDF template as a mini web application with its own input surface: the data that flows into it, the resources it loads, and the output it produces are all attackable, even though the template itself is just a string. Reviewing templates with that mindset catches the majority of issues at design time — before they become code — which is where security fixes are cheapest and least disruptive.

The XSS Attack Surface: Three Typical Injection Scenarios

The first scenario is direct HTML injection: user input is concatenated into the template string, so a name field containing a script tag or an img tag with an onerror attribute is parsed as markup. PDF rendering constrains script execution somewhat, but preview flows and template reuse can execute the payload in a page context, and the injected content itself pollutes the PDF's appearance and credibility. There is no scenario where letting raw markup through is acceptable.

The second scenario is URL injection: user-supplied links or image addresses are written into href and src attributes, and malicious values can be javascript: pseudo-protocols, oversized data: payloads, or URLs pointing at internal addresses that trigger probing requests. The browser may act on these during parsing and loading — breaking functionality at best, enabling phishing and information gathering at worst. Every external URL must pass a protocol whitelist before it enters a template.

The third scenario is attribute escape: user input placed inside an HTML attribute, containing unescaped quotes or special characters, can close the attribute and inject new tags — expression values in style, unexpected structure in class, and similar tricks. This is subtler than direct tag injection and easier for hand-rolled filters to miss. The correct response is a mature sanitizer like DOMPurify for all input handling rather than bespoke regexes, because hand-written filtering can never keep pace with the ways markup can be disguised.

Injection payloads also target the PDF's consumers rather than the generator: a link inside the PDF that points to a phishing page, or a QR code that encodes a malicious URL, turns a legitimate document into a delivery vehicle. Validate and normalize URLs in templates not only to protect the rendering environment but also to protect the people who will read the file after it leaves your application. Keep the template's link policy explicit — http/https only, no javascript: or data: targets — and enforce it in the sanitizer configuration rather than at individual call sites.

Code Example: Sanitizing Input with DOMPurify

DOMPurify is the de facto standard for frontend HTML sanitization. It parses the input into a real DOM and rebuilds it against a whitelist, stripping every tag, attribute, and protocol that is not explicitly allowed, and returning a clean HTML string. Because it operates on an actual DOM parse rather than string matching, it handles nesting, entities, and encoding variations correctly, and attackers have a very hard time finding bypasses — which is precisely why it has earned the community's trust.

Keep the whitelist as narrow as the template genuinely requires. Allow only the tags and attributes the PDF template uses: img src should be restricted to http/https protocols (DOMPurify does this by default), and if the style attribute is not needed, remove it from ALLOWED_ATTR entirely. The example sets ALLOW_DATA_ATTR to false so data-* attributes cannot be used as a smuggling channel. A narrower whitelist is a smaller attack surface, and that is the first principle of sanitizer configuration.

The order of operations matters: sanitize user input first, then concatenate into the template — never the reverse. The template's static structure is trusted, but every dynamic value — order IDs, user names, notes — must go through escapeHtml or DOMPurify, one or the other, with no exceptions. Put this rule in the code review checklist so every new template concatenation point is examined before it ships; that is how XSS entry points actually get closed in a codebase that keeps growing.

DOMPurify's configuration should live in one module and be imported everywhere templates are built, so the policy is audited once rather than re-declared at every call site. When the policy needs to change, the single source of truth makes the impact review trivial; scattered configurations, by contrast, guarantee that some call sites will lag behind the policy and quietly weaken the overall defense. Version the policy module so templates can be regenerated with older policies when a change is rolled back.

import DOMPurify from 'dompurify';
import { DomPDF } from 'dompdf.js';

const POLICY = {
  ALLOWED_TAGS: [
    'p', 'h1', 'h2', 'h3', 'strong', 'em', 'ul', 'ol', 'li',
    'table', 'thead', 'tbody', 'tr', 'td', 'th', 'br',
    'span', 'div', 'img', 'a', 'blockquote', 'code', 'pre',
  ],
  ALLOWED_ATTR: ['src', 'alt', 'href', 'width', 'height', 'style', 'class'],
  ALLOW_DATA_ATTR: false,
};

function buildPdfFromUserInput(userHtml, orderId) {
  const safeHtml = DOMPurify.sanitize(userHtml, POLICY);
  const template = `<h1>Order ${escapeHtml(orderId)}</h1>${safeHtml}`;
  const pdf = new DomPDF();
  pdf.addPage(template, { format: 'A4' });
  pdf.save('order.pdf');
}

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}

CSP and External Resource Policy

Content-Security-Policy is the second layer of defense in depth: even if sanitization fails, CSP limits what injected code can do. For pages that host PDF generation, configure a strict default policy — script-src restricted to same-origin and the CDNs you actually need, img-src limiting image sources, connect-src limiting request targets. CSP does not replace sanitization, but it turns a single-point failure into a contained incident, and the two together form the complete defense.

External resources deserve separate attention: fonts, images, and CSS referenced from templates all come from the network, and an attacker can craft URLs pointing at internal IPs or local services, using the browser as a proxy to probe them — the SSRF variant of this attack surface. The countermeasures are validating that every external URL uses http or https, enforcing a domain whitelist or at least format validation, and self-hosting fonts and images where possible to minimize uncontrollable external dependencies.

Add integrity checks to the supply chain: enable Subresource Integrity on critical static assets, and the browser refuses to load a resource whose hash does not match, neutralizing CDN tampering. The wasm file, core JavaScript, and template fonts can all carry integrity attributes. These policies are configured once and stay in effect indefinitely, which makes them among the highest-ROI security investments available — well worth doing at the deployment stage rather than after an incident.

CSP configuration interacts with the export pipeline in one surprising way: if your policy blocks inline styles or fonts from third-party origins, the PDF template may render differently than the page, because the template's own CSS is evaluated in the page's security context. Test the export flow under the production CSP, and if the template needs fonts or styles that the policy forbids, either adjust the policy deliberately or inline the resources into the template — but make the trade-off explicit rather than accidental.

Protecting Sensitive Data and Encrypting PDFs

Sensitive data in a PDF can leak through three routes: the file is forwarded after download, it is stored somewhere untrusted, or it is captured by logging and monitoring systems. On the generation side, minimize exposure — templates should include only business-necessary data, with sensitive fields like ID numbers and phone numbers masked on demand so complete values never enter the file. On the logging side, keep template contents and user data out of logs; error reports should carry error codes and summaries rather than payloads.

dompdf.js supports PDF encryption and permission control: you can set a user password and an owner password on the generated document and restrict operations like printing and copying. For documents that contain confidential information, enabling encryption at generation time is basic respect for the content; for documents that must be archived long-term, encryption also prevents easy content extraction. Manage the passwords through your key management system rather than hardcoding them in application code.

Design permission control around the business scenario: contracts typically allow viewing and printing but restrict copying; internal reports may restrict printing; externally delivered files can be read-only. Parameterize encryption and permissions in the export service layer's configuration, so different document types follow different policies without complicating business code. Consistent policy enforcement is also what makes security audits straightforward, because the rules live in one place instead of scattered across feature code.

Encryption protects content at rest, but the password itself becomes a management problem: if every exported contract shares one password, the protection is symbolic; if passwords are per-document, users lose them. For interactive exports, generate a password, deliver it to the authorized recipient through a separate channel, and store the mapping in the backend; for automated exports, derive passwords deterministically from the key management system so the PDFs remain decryptable without keeping a fragile database of secrets.

Security Checklist and Common Misconceptions

An executable security checklist: sanitize all user input with DOMPurify before concatenation; escape every dynamic value with escapeHtml; allow only http/https URLs in img and a attributes and validate them; configure CSP covering script, img, and connect directives; enable SRI on critical assets; self-host or same-origin the wasm and font files; mask sensitive fields before they enter the PDF; enable encryption and permission control for confidential documents; keep template contents and user data out of logs; and include security configuration in code review and release checks.

Misconception one: since generation happens in the frontend with no server, there is no injection risk. In fact the opposite is true — sanitization duty falls entirely on the frontend, and a single missed concatenation point is a vulnerability. Misconception two: regex filtering is an acceptable substitute for a mature sanitizer. Regexes cannot cover encoding variants and nested structures; DOMPurify is battle-tested and there is no good reason to hand-roll this. Misconception three: a security review before launch is enough. Templates and features evolve, and the checklist must evolve with them, re-examining every new concatenation point.

Embed security practices into process: template concatenation points must pass review, dependency upgrades run a security regression, and DOMPurify plus dompdf.js stay current to receive security fixes. Security is not a one-time patch but a continuous process constraint. When every developer defaults to sanitize-first, URL-validate, data-minimize behavior, the PDF generation feature stays secure over the long run and survives both audits and attacks.

Security review checklists work best when they are executable rather than aspirational: turn each item into a test where possible — a unit test that asserts sanitized output contains no script tags, a build check that fails when a new template concatenation point appears without a sanitizer call, a CSP report-only mode in staging that surfaces violations before they reach production. Automated checks catch regressions that human reviews miss, and they keep working when the team changes.

⚡ 现场演示(点击生成 PDF)

下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:

Hello from dompdf.js!

这是由 dompdf.js 渲染的示例 PDF 内容。