A server that turns HTML into PDF is a server-side browser, and when user-controlled content reaches it, that browser can be made to fetch internal URLs, read local files and embed the results in a document the attacker downloads. Invoices, reports, certificates, tickets and exported dashboards are often rendered this way, usually by a component added late and reviewed as "just a library". This guide is for application security engineers, developers and platform teams who run HTML to PDF conversion. It covers how the risk arises, the settings each common engine exposes, isolation and egress design, testing, detection and a phased rollout. It goes deeper on renderers than the SSRF Prevention Guide, and it is about generating PDFs, not opening malicious ones, which the PDF Security Guide covers. Engines are listed alphabetically; order is not a ranking.
Key takeaways
- The renderer is a fetcher. Every image, stylesheet, font, iframe, SVG reference and script in the HTML is a request made from your server, with your server's network position.
- Template data is input. A customer name, address or note inserted unescaped into the HTML template is enough to inject markup that the renderer will act on.
- Control resource loading in the engine. Each engine has its own URL fetcher, allow and deny lists or request interception; set them so only the resources you intend can load.
- Isolate and constrain egress. Run the renderer as an unprivileged, sandboxed, resource-limited service with network access only to the hosts it needs, never to metadata endpoints or internal admin services.
- Treat the output as sensitive. A PDF can carry leaked file contents, attachments and system details, so check what the generator embeds before the file leaves the service.
How PDF generation becomes SSRF and file read
HTML to PDF engines either drive a real browser engine (Chromium through Puppeteer, Playwright or a service such as Gotenberg; the QtWebKit engine inside wkhtmltopdf) or implement their own HTML and CSS layout (dompdf, WeasyPrint). Either way, the engine resolves references while it lays out the page. If an attacker can place markup in the HTML, the engine makes requests on their behalf. That is server-side request forgery (CAPEC-664) delivered through a document.
Typical injection points are fields inserted into a template without output encoding, rich text editors whose HTML is rendered as is, "export this page as PDF" features that render a user-supplied URL, and Markdown or CSS customisation features. The impact depends on what the renderer can reach and what it does with the response.
| Outcome | How it happens | What the attacker gains |
|---|---|---|
| Internal request (blind SSRF) | An injected image or stylesheet reference points to an internal host | Port and service discovery, requests to unauthenticated internal APIs, side effects on internal services |
| Response embedded in the PDF | An iframe, object or script fetches a URL and the rendered page includes the content | Internal page contents, including cloud metadata responses, visible in the downloaded document |
| Local file read | A file:// reference resolves on the server | Configuration files, keys and environment details embedded in the PDF |
| Attachment of files | Engines that support PDF attachments embed a referenced file | Whole files carried inside the PDF, not just rendered text |
| Denial of service | Endless rendering, huge CSS values, hanging network requests or special files | Exhausted CPU and memory on the rendering service |
| Script execution in the renderer | Engines with JavaScript enabled run injected script in the page | Programmable requests, reading responses and writing them into the page |
WeasyPrint's own security documentation lists most of these: long and endless renderings, network requests that hang, huge CSS values, local file access through file:// URIs (including special files that cause infinite rendering), attachment of any reachable file, leakage of installed fonts, network configuration and library versions, and the same risks through SVG images. The same categories apply to every engine.
Why cloud credentials make this urgent
On cloud hosts the most valuable internal URL is the instance metadata service. The OWASP SSRF Prevention Cheat Sheet lists 169.254.169.254 (used by AWS, Azure and Google Cloud), metadata.google.internal and the AWS IPv6 address fd00:ec2::254 as addresses to block, along with link-local ranges that cover container credential endpoints. A renderer that can reach these and embed the response hands out the workload's role credentials (ATT&CK T1552.005 Cloud Instance Metadata API). The Capital One Breach 2019 shows the pattern: an SSRF reached the metadata service and returned temporary role credentials that could read far more data than the component needed.
On AWS, IMDSv2 requires a session token obtained with a PUT request, rejects PUT requests carrying an X-Forwarded-For header and by default limits the token response to a hop limit of 1. An injected HTML reference makes GET requests, so requiring IMDSv2 (HttpTokens set to required) removes the simplest route. AWS notes that containers may need a hop limit of 2, which weakens the protection slightly, so keep the smallest value that works. See the Cloud Instance Metadata Services Compared Guide for the equivalent settings on each cloud.
Engine settings that control resource loading
Each engine exposes its own controls. The table lists the settings and guidance each project documents; confirm them against the current documentation for your version.
| Engine | Resource loading controls (from project docs) | Other documented guidance |
|---|---|---|
| dompdf | Remote resources load only when the isRemoteEnabled option is true; local files must sit inside the paths set in the chroot option | Embedded PHP is disabled by default and should stay off; never let users change Options values; keep dompdf outside the web root; validate external resource references when users supply HTML |
| Gotenberg (Chromium and LibreOffice) | --chromium-deny-list defaults to blocking file:// URLs outside /tmp; --chromium-allow-list; --chromium-deny-private-ips (default false); --chromium-disable-javascript; equivalent allow, deny and private IP flags for webhooks, downloadFrom and LibreOffice | --api-timeout defaults to 30s; basic or OIDC authentication can be enabled; LibreOffice macros are off by default; keep --chromium-disable-web-security off |
| Puppeteer (Chromium) | page.setRequestInterception(true) with a request handler that calls abort, continue or respond for every request | Run Chrome with its sandbox; the project strongly discourages --no-sandbox and allows it only for content you absolutely trust; its Docker example adds an unprivileged user so the sandbox works |
| WeasyPrint | A custom URL fetcher that blocks file:// or filters by protocol and path; URLFetcher timeout setting | Run as a non-root user with limited file system, network and memory access, ideally in a container; limit rendering time and memory per process |
| wkhtmltopdf | Process-level controls: sanitise HTML before rendering and confine the process with a mandatory access control system such as AppArmor or SELinux | The maintainer's status page says not to use it with any untrusted HTML, and suggests WeasyPrint or Prince for HTML you control and Puppeteer for JavaScript-heavy pages |
Availability: Gotenberg flag names and defaults are from its configuration documentation as of November 2026. Puppeteer interception is a page-level API, so apply it to every page the service opens. WeasyPrint's URL fetcher is also used for SVG images, so a custom fetcher covers those too. Settings move between versions; record the version you validated.
Whatever the engine, the pattern is the same: deny by default, allow the specific scheme and host that serve your own assets (ideally a local asset directory or one internal static host), and refuse file:// except a dedicated, empty-by-default template directory.
Designing a safe rendering service
- Separate service: run rendering as its own service or container, not inside the main application process, so a compromise of the renderer does not reach application secrets or the database connection.
- No credentials in reach: give the renderer no cloud role, or a role with no permissions, and no mounted secrets. A renderer needs fonts, templates and the HTML, nothing else. Where the platform injects credentials by default, turn that off for this workload.
- Egress allow-list: at the network layer, allow only the asset host and nothing else. Block metadata addresses and private ranges at the host or network policy even if the engine also blocks them. See Cloud Egress Filtering.
- Self-contained input: embed images and fonts as data URIs or local files resolved by your code before rendering, and turn off remote loading in the engine. This removes most of the fetch surface.
- Encode template data: insert user data into templates with HTML output encoding; where rich text is required, sanitise it with an allow-list sanitiser first. See the HTML Sanitisation Guide.
- JavaScript off unless needed: most document templates need no script. Disable it in the engine where the option exists, or add a Content Security Policy in the template that blocks script and restricts img-src and style-src.
- URL rendering features: if users can ask the service to render a URL, apply the full SSRF controls to that URL: allow-listed hosts, resolved IP checks, no redirects to other hosts.
- Resource limits: cap rendering time, memory, page count and output size per request, and kill processes that exceed them.
- Authentication on the service: rendering APIs such as Gotenberg should not be reachable without authentication or from outside the application network.
Decision guidance
| Situation | Approach | Trade-off |
|---|---|---|
| Fixed templates, simple layout, data fields only | Layout engine with remote loading off and local assets, or a non-HTML PDF library that draws text directly | Least attack surface; less layout flexibility |
| Complex layout needing modern CSS or JavaScript charts | Headless Chromium in a sandboxed, unprivileged container with request interception or engine allow-lists and an egress allow-list | Larger runtime to patch and harden; strongest rendering fidelity |
| Users can supply rich HTML or Markdown | Sanitise with an allow-list, remove external references, then render with remote loading off | Some formatting is lost; sanitiser needs maintenance |
| Users can supply a URL to render | Dedicated renderer with no internal network access at all, plus SSRF validation on the URL | Separate network segment and operations cost |
| Small team, low volume | One hardened container with remote loading off, JavaScript off and a strict egress policy | Simple to run; review on each engine upgrade |
| Large platform, many teams | Shared internal rendering service with a fixed security configuration, authentication and central logging | Teams lose per-service tuning; one place to fix and monitor |
Engines that are no longer actively developed carry more risk with untrusted input because browser engine flaws go unfixed. Where an engine's own maintainers advise against untrusted HTML, plan a migration, and until then render only fully trusted, server-built HTML.
Implementation roadmap
- Find every renderer: search code and dependency manifests for PDF and headless browser libraries, container images that bundle them and internal rendering APIs. Forgotten renderers in reporting and export features are common.
- Map the data flow: for each, record which fields reach the HTML, whether users can supply HTML or URLs, and what the process can reach on the network and file system.
- Quick wins first: require IMDSv2 or the equivalent on renderer hosts, block metadata and private ranges in network policy, and turn off remote loading where the templates do not need it.
- Fix encoding: add output encoding to template fields and sanitise any rich text.
- Isolate: move renderers into separate, unprivileged, sandboxed containers with no credentials and resource limits.
- Test in a pilot: deploy the restricted configuration to one document type, compare output against the previous version and watch blocked request logs for assets you missed.
- Roll out and roll back safely: keep the previous configuration deployable behind a flag; if documents break, add the missing asset to the allow-list rather than reopening network access.
Testing
Include rendering features in application security testing and penetration test scope explicitly, because generic scanners rarely reach them. Assessors should confirm, using harmless markers, whether template fields accept markup, whether the renderer contacts an external collaborator host, whether file:// and private address references are refused, and whether script runs. Add regression tests that render a template containing an external reference and a file reference and assert that the output contains neither. Check generated PDFs for unexpected attachments and embedded content. The File Upload Security Guide covers the related case where users upload HTML or documents for conversion.
Detection and measurement
What to log and watch:
- Renderer egress: every outbound connection from rendering hosts, with destination; alert on anything outside the allow-list, especially metadata addresses and private ranges.
- Blocked requests: engine-level aborts from interception handlers, URL fetchers or deny lists, logged with the document type and request ID.
- Metadata access: cloud audit events showing use of the renderer's credentials, which should be none.
- Resource exhaustion: timeouts, killed processes and oversized outputs, which can indicate probing.
- Template input: markup characters arriving in fields that should be plain text.
Metrics that show the programme is working:
- Percentage of renderers running isolated with no credentials and an egress allow-list (target 100%).
- Percentage of renderers with remote resource loading disabled or allow-listed.
- Count of renderer egress connections to non-allow-listed destinations per month (target zero after tuning).
- Rendering features covered by regression tests for external and file references.
- Median age of the rendering engine version against the latest release.
Framework mapping
| Framework | Reference | How it applies |
|---|---|---|
| OWASP ASVS 5.0 | V1.3.6 | Protect against SSRF by validating untrusted data against an allow-list of protocols, domains, paths and ports |
| CAPEC | CAPEC-664 Server Side Request Forgery | The attack pattern renderers are exposed to |
| MITRE ATT&CK v20 | T1190 Exploit Public-Facing Application; T1552.005 Cloud Instance Metadata API | Entry through the rendering feature; credential theft from metadata |
| NIST SP 800-53 Rev 5 | SI-10 Information Input Validation; SC-7 Boundary Protection; SC-39 Process Isolation; CM-7 Least Functionality; SI-4 System Monitoring | Encoding and sanitising input; egress control; isolated renderer; disable remote loading and script; monitor egress |
| NIST CSF 2.0 | PR.PS-04, DE.CM-09 | Logging from the rendering service and monitoring of its runtime |
| CIS Controls v8.1 | Control 16 Application Software Security; Control 13 Network Monitoring and Defense | Secure development of rendering features; egress monitoring |
| ISO/IEC 27001:2022 Annex A | A.8.28 Secure coding; A.8.22 Segregation of networks; A.8.16 Monitoring activities | Encoding and testing; isolated network segment; renderer monitoring |
Where identity fits
The renderer's workload identity is the prize in most SSRF chains. Give rendering services their own identity with no permissions, never reuse the application's role, and alert on any use of it. The EC2 Instance Metadata Service Hardening Guide covers the AWS side in depth.
Common failure patterns
- Trusting the template: teams secure the URL feature but forget that a customer name or address inserted into an invoice template is also HTML input.
- Renderer inside the main app: the PDF library runs in the application process with its role, secrets and database access, so file read or SSRF reaches everything.
- Deny-lists only: blocking 169.254.169.254 as text while allowing redirects, alternative encodings or IPv6 forms; OWASP warns deny-lists are bypass-prone and redirects should be disabled.
- Sandbox switched off: Chromium started with --no-sandbox to make a container work, against the Puppeteer project's guidance, removing a key layer for untrusted content.
- Unauthenticated rendering API: an internal conversion service reachable by anything on the network, which turns it into a general-purpose fetch proxy.
- Stale engine: an unmaintained engine kept for layout compatibility and fed customer-supplied HTML.
Practitioner checklist
- Inventory every HTML to PDF renderer, including those inside reporting and export features.
- Encode all template fields and sanitise any rich text before rendering.
- Disable remote resource loading or set an engine allow-list; refuse file:// outside a template directory.
- Disable JavaScript where templates do not need it.
- Run renderers as unprivileged, sandboxed, resource-limited services with no credentials.
- Enforce an egress allow-list and block metadata addresses and private ranges at the network layer.
- Require IMDSv2 or the equivalent metadata protection on renderer hosts.
- Authenticate access to any internal rendering API.
- Add regression tests for external and file references and include rendering in pen test scope.
- Log and alert on renderer egress outside the allow-list.
Standards and references
- OWASP: Server-Side Request Forgery Prevention Cheat Sheet
- OWASP: Application Security Verification Standard 5.0
- AWS: Configure the instance metadata service options
- dompdf: Securing dompdf
- Gotenberg: Configuration
- Puppeteer: Troubleshooting (Chrome sandbox)
- WeasyPrint: First steps and security
- wkhtmltopdf: Project status
Related NHI Mgmt Group resources: SSRF Prevention Guide · PDF Security Guide · HTML Sanitisation Guide · Cloud Instance Metadata Services Compared Guide · Container Security Guide