Back to Blog
SecurityJuly 1, 20265 min read

PDF Generation Security: Stopping mXSS and SSRF in Your App

PDF generation security requires blocking mXSS and SSRF. Learn how to harden headless browsers in Node.js and PHP to prevent data exfiltration and injection.

securitynodejsphppuppeteerweb-securitypdf-generationWebBackend

When I first implemented a "Download as PDF" feature, I didn't think twice about passing raw HTML strings to a headless browser. It seemed like a standard task until a security audit revealed that our internal invoice generator was effectively acting as a proxy for the entire office network.

PDF generation security is a massive blind spot for many teams. We treat these services as simple document printers, but they are actually full-blown browser instances running on your server. If you don't restrict them, you're essentially giving an attacker a browser inside your infrastructure.

Understanding the Risk: mXSS and SSRF

When you send user-controlled data to a library like Puppeteer or a PHP wrapper for Chromium, you're creating two primary attack vectors.

First, there is mXSS (Mutation Cross-Site Scripting). This happens when your server-side browser parses malformed HTML. The browser "fixes" the broken tags, which can inadvertently turn harmless-looking strings into executable JavaScript. If that script runs, it can steal cookies, access local files, or scrape sensitive internal data from the document context.

Second, there is SSRF (Server-Side Request Forgery). Since the headless browser is running inside your network, it can access internal URLs that aren't exposed to the public internet. If an attacker can inject an <img> tag with a src pointing to http://internal-admin-panel/delete-user, the browser will execute that request with the server's identity.

Securing Headless Browsers

We initially tried to sanitize the HTML using standard regex patterns. That failed immediately because the browser's parsing engine is much more complex than any regex we could write. We learned that you cannot "clean" your way out of this; you have to lock down the execution environment.

1. Disable JavaScript Execution

If your PDF generation doesn't require dynamic charts or complex UI rendering, disable JavaScript entirely. In Puppeteer (v22.x), you can do this at the page level:

JAVASCRIPT
const page = await browser.newPage();
await page.setJavaScriptEnabled(false);

This single line effectively kills most mXSS vectors. If you need dynamic content, use a dedicated, isolated environment.

2. Restrict Network Access

To stop SSRF, you must prevent the headless browser from reaching internal resources. I prefer using a combination of --block-new-window and custom network interception. Here is how I set up an allow-list in Node.js:

JAVASCRIPT
await page.setRequestInterception(true);
page.on(CE9178">'request', (request) => {
  const url = new URL(request.url());
  const allowedDomains = [CE9178">'cdn.example.com', CE9178">'fonts.googleapis.com'];
  
  if (allowedDomains.includes(url.hostname)) {
    request.continue();
  } else {
    request.abort();
  }
});

3. Isolate the Environment

Never run PDF generation on the same instance that handles your primary API. Even with strict headers, a breakout could lead to arbitrary file write if the browser process is compromised.

I recommend offloading this to a ephemeral container or a serverless function (like AWS Lambda) that has no access to your VPC or internal credentials.

Comparison: Securing PDF Pipelines

Mitigation StrategyEffectivenessComplexityPerformance Impact
Disable JSHighLowNone
Network InterceptionHighMediumMinor
Sandboxed ContainerVery HighHighModerate
Input SanitizationLowMediumHigh

Implementation Patterns

When we refactored our architecture, we stopped passing raw HTML strings entirely. Instead, we moved to a template-based system where the server only accepts a JSON object.

Flow diagram: Client Request → API Controller; API Controller → Validate JSON; Validate JSON → Valid Template Engine; Validate JSON → Invalid Reject; Template Engine → Sanitized HTML; Sanitized HTML → Headless Browser; Headless Browser → PDF Output

This pattern ensures that the browser never sees user-provided HTML, only data processed by your own templates. This is a massive improvement over the previous approach of trusting client-side input.

If you are using PHP, you might be tempted to use libraries that execute shell commands to trigger wkhtmltopdf or similar binaries. I strongly advise against this. These libraries often suffer from command injection vulnerabilities if the input isn't perfectly escaped. Stick to modern, memory-safe wrappers that communicate over pipes or local sockets.

Frequently Asked Questions

Can I just use a library to sanitize the HTML? No. Sanitization libraries are built for client-side browsers. The headless browser version and the library's parsing logic might differ, leading to "mutation" that the sanitizer won't catch. Always prefer templating over raw HTML.

Is SSRF really that dangerous if I'm behind a firewall? Yes. SSRF allows an attacker to bypass your firewall by using your server as a pivot point. If your server can reach internal services (like Redis, metadata endpoints, or admin dashboards), the attacker can reach them too.

Does disabling JavaScript break CSS-based page breaks? Generally, no. Most modern headless browsers handle print CSS (@media print) perfectly fine without full JS execution. Test your layouts thoroughly, but you'll likely find you don't need the runtime overhead.

Final Thoughts

The most important lesson I learned during our last refactor is that PDF generation is an infrastructure problem, not just a coding problem. We eventually moved our PDF service to a separate, restricted worker pool.

I’m still not 100% comfortable with headless browsers on the critical path. If I were building this from scratch today, I’d look into server-side PDF generation libraries that don't rely on a full browser engine at all, as that removes the entire class of browser-based vulnerabilities. For now, strict network isolation remains the best defense we've got.

Similar Posts