reportgen
Back to all posts

Build Your Own PDF API, Part 5: Security, or Why Templates Are Untrusted Code

November 2, 2026

Part 5 of 6 of Build Your Own ReportGen.

Here's the uncomfortable truth about every HTML-to-PDF service: it's a web browser, running inside your network, loading HTML that someone else wrote.

If any part of the HTML can be influenced by a user (a customer-uploaded template, a "company name" field, a markdown note, a URL to render), then that user can make your server's browser load things. And your server can reach places the internet can't.

This is the part most DIY tutorials skip. Don't skip it.


The Attack: SSRF Through a PDF

SSRF (Server-Side Request Forgery) means tricking a server into making requests on the attacker's behalf. A PDF renderer makes it trivially easy, because loading resources is literally its job. And unlike most SSRF, the attacker gets to see the response: it's printed right into the PDF they download.

Some examples of what a malicious template can try:

<!-- Cloud metadata: on AWS, GCP and Azure this can return temporary credentials -->
<iframe src="http://169.254.169.254/latest/meta-data/iam/security-credentials/"></iframe>
 
<!-- Your internal admin panel, database UI or monitoring that "isn't exposed" -->
<iframe src="http://10.0.0.12:8080/admin"></iframe>
 
<!-- Local services on the worker itself -->
<img src="http://localhost:6379/">
 
<!-- JavaScript can probe and read responses, then write them into the page -->
<script>
  fetch("http://internal-api.local/users").then(r => r.text())
    .then(t => document.body.innerText = t);
</script>

The cloud metadata one is the worst: on many cloud setups, that URL hands out credentials for whatever IAM role the machine has. The attacker downloads their "invoice", and it contains your cloud keys.


The Defense, in Layers

No single control is enough. Use several.

Layer 1: Block private network destinations in the browser

This is the lockDownNetwork function from Part 4's browser pool:

async function isPublicHost(hostname) {
  try {
    const addrs = await dns.lookup(hostname, { all: true });
    return addrs.length > 0 && addrs.every(({ address }) => ipaddr.process(address).range() === "unicast");
  } catch {
    return false;
  }
}
 
async function lockDownNetwork(page) {
  await page.setRequestInterception(true);
  page.on("request", async (req) => {
    if (req.isInterceptResolutionHandled()) return;
    const url = new URL(req.url());
    const allowed =
      url.protocol === "data:" ||
      url.protocol === "about:" ||
      (url.protocol === "https:" && (await isPublicHost(url.hostname)));
    if (allowed) req.continue();
    else req.abort("blockedbyclient");
  });
}

Every request the page makes (images, stylesheets, fonts, iframes, fetch, XHR) goes through this handler first. It allows:

  • data: URLs (inline images and fonts),
  • https: URLs whose hostname resolves only to public IP addresses.

Everything else is blocked: plain http:, file:, and any hostname that resolves to a private, loopback, link-local or otherwise reserved address. ipaddr.js classifies addresses into ranges, and only unicast (normal, public internet addresses) passes. That covers 127.0.0.1, 10.x, 172.16–31.x, 192.168.x, 169.254.x (the metadata endpoint), IPv6 equivalents, and IPv4-mapped IPv6 tricks like ::ffff:127.0.0.1.

We tested it against each of these, with a fake "internal service" listening locally:

Template tried to load Result
http://127.0.0.1:4580/admin Blocked
https://localhost:4580/x Blocked (resolves to loopback)
http://169.254.169.254/latest/meta-data/ Blocked
fetch("http://127.0.0.1:4580/api") from a script Blocked
<iframe src="file:///etc/passwd"> Blocked (Chrome itself refuses file: from a setContent page)
https://www.google.com/favicon.ico Loaded

The internal service received zero requests.

Why block plain http: entirely? Mostly to keep the rules simple, and because there's no good reason for a document to load insecure resources in 2026. If you must allow it, run the same isPublicHost check on http: too.

Layer 2: Block egress at the network level

Layer 1 has a known gap: DNS rebinding. We resolve the hostname, see a public IP, and allow the request. But then Chrome resolves it again to actually connect, and a malicious DNS server can answer with 127.0.0.1 the second time.

The fix is to not rely on the browser alone. Make the network itself refuse:

  • Put PDF workers in their own network/subnet with egress rules that deny private ranges and the metadata IP.
  • On AWS, require IMDSv2 (token-based metadata), which plain GET requests from a browser can't use. GCP and Azure require a special header that a browser can't send from an <img> either, but an egress rule is still the safest bet.
  • Give the worker's machine/role no cloud permissions it doesn't need. If the worker only writes to one bucket, its credentials should only allow that.

Layer 1 stops the casual attempts and gives clear errors. Layer 2 stops the clever ones.

Layer 3: Use setContent, never file://

A common pattern is to write the HTML to a temp file and call page.goto("file:///tmp/report.html"). Don't. A page loaded from file:// is allowed to load other file:// URLs, so <iframe src="file:///etc/passwd"> or your app's .env file can end up in the PDF.

page.setContent(html) loads the HTML into an about:blank page, and Chrome refuses to load local files from there. Our interceptor blocks file: as well, as a second line of defense.

Layer 4: Turn off JavaScript when you don't need it

If your templates don't use JavaScript (no charts, nothing dynamic), disable it per page:

await page.setJavaScriptEnabled(false);

That removes fetch, XHR, timing attacks and runaway scripts in one line. If only some templates need JS, make it a per-template setting and default it to off for anything user-submitted.

Layer 5: Limit everything

  • Request body size: express.json({ limit: "5mb" }) from Part 1. A huge template is a cheap way to tie up a worker.
  • Render time: the hard deadline from Part 4.
  • Page count: a {{#each}} over a million rows can produce a 50,000-page PDF. Cap input array sizes in validation, or check the page count after rendering.
  • Concurrency per customer: one customer submitting thousands of jobs shouldn't starve everyone else. Add a per-customer limit on queued and running jobs.
  • Rate limit the API in front of it all.

Other Things That Bite

Template injection. Use your engine's escaping. Handlebars escapes {{value}} by default and only outputs raw HTML with {{{value}}}. Every triple-stash with user data is a hole where someone can inject <script> or <iframe> tags. Search your templates for {{{ and justify each one.

Webhook URLs. In Part 3, the worker POSTs to a URL the user gave you. That's SSRF from Node instead of Chrome. Run isPublicHost on the webhook hostname before calling it, and don't follow redirects to private addresses.

"Render this URL" features. If you let users submit a URL instead of HTML, apply the same checks to the URL itself before page.goto(), not just to the subresources it loads.

Sensitive data in PDFs. Generated PDFs often contain personal data. Keep the bucket private, use short-lived signed URLs (Part 3 uses one hour), and delete old files with a lifecycle rule.

Abuse. If your service is public or has a free tier, expect people to use it for things you don't want to host: fake receipts, fake invoices, phishing documents. Log who generated what, keep the ability to ban accounts, and watch for unusual spikes from new accounts.


A Security Checklist

  • Network interception blocks private, loopback, link-local and metadata addresses
  • Workers run in a network segment with egress deny rules for private ranges
  • Cloud metadata requires tokens (IMDSv2) or is unreachable from workers
  • Worker credentials only allow what workers need (one bucket)
  • HTML is loaded with setContent, never from file://
  • JavaScript is off unless a template needs it
  • Body size, render time, page count and per-customer concurrency are limited
  • No {{{triple-stash}}} with user data
  • Webhook URLs are checked like template URLs
  • Chrome runs as a non-root user (Part 6)

Frequently Asked Questions

My templates are all written by my own team. Do I still need this? Probably less, but check where the data comes from. If a customer's company name or a note field ends up in the HTML through a triple-stash or a markdown renderer, users influence the HTML. Layers 1 and 3 cost almost nothing, so keep them regardless.

Can't I just use an allowlist of hostnames instead? Yes, and if you can, it's even safer: allow only your CDN and Google Fonts, for example. The public-IP check is for services where users legitimately reference images from anywhere.

Is Chrome's own sandbox enough? Chrome's sandbox protects the machine from a malicious page exploiting a Chrome bug. It does nothing about SSRF, because loading URLs is normal browser behavior. You need both.


Next: Part 6: Docker, Hosting and Scaling. Packaging it all into a container that has the right fonts, doesn't run as root, and scales.