Security model
PDF Server executes templated HTML in a real browser to produce PDFs. That makes the browser the security-critical component and the request data the primary untrusted input. This page defines the trust boundaries; the specific issues and fixes are in Proved issues and Possible issues.
Trust boundaries
| Actor / input | Trust | Rationale |
|---|---|---|
| Template author | Trusted | You write the templates, schemas, compositions (composition.js/jq), and remote-request templates, and you build the image. Author-supplied code is trusted. |
| Render-request data | Often untrusted | In many deployments the JSON body carries end-user input (a customer name/address on an invoice). Treat it as attacker-controlled unless you know otherwise. |
| The headless browser | Weak boundary | It executes whatever HTML/CSS/JS the render produces. It runs with --no-sandbox, so a renderer compromise lands as the container user, and nothing restricts the URLs the page itself fetches. |
| Operator | Responsible | The service ships secure defaults for body size, timeouts, and remote-fetch SSRF (below), but no auth, no browser OS sandbox, and no restriction on the browser's own egress — you provide those. |
The /_debug surface | Off by default, unauthenticated when on | Gated behind --debug-enabled (DEBUG_ENABLED); when enabled it carries none of its own authentication — see below. |
Two common deployment shapes
- Untrusted data (e.g. public-facing invoices): the request body contains end-user input. All data-driven issues below are in scope. Harden accordingly.
- Internal data only (e.g. reports from your own systems): the body is trusted. Data-driven issues (XSS-into-PDF, URL SSRF from data) are largely not reachable, but network exposure and DoS considerations still apply.
Knowing which shape you are in tells you which issues to prioritise — each issue in the proved and possible lists is tagged with when it is reachable.
What the service provides — and what it does not
PDF Server now ships secure defaults for several controls. The transport limits are configurable via flags/env (see the reference); the remote-fetch and JS-planner limits are fixed in code:
- a request body-size limit (
--server-max-body-size, default 1 MiB); - HTTP read-header / read / idle timeouts (
--server-*-timeout); - SSRF protection on remote-template fetches — a fixed fetch timeout, a fixed response-size cap, and a block on loopback/private/link-local destinations that cannot be turned off process-wide;
- an execution deadline for JavaScript composition planners — the shared runtime is interrupted when the request deadline (capped at 30s) fires, so an infinite loop can no longer hang the request;
- unguessable internal document IDs (
crypto/rand), so the internal render server's per-render keys are not predictable from the render count.
It still ships no:
- authentication or authorization on any endpoint;
- browser OS sandbox — it runs
--no-sandbox, and dropping that flag breaks Chromium in the default container (the sandbox needs host-level user namespaces / a compatible seccomp profile); - restriction on the browser's own outbound network — the SSRF guard above covers only remote-template fetches, not URLs the rendered page itself loads.
Restricting the browser's egress and running it under an OS sandbox therefore remain operator-side controls. See the hardening checklist.
The /_debug surface
The service can expose a set of debugging routes under /_debug — rendering
arbitrary caller-supplied data against any loaded template and returning the
rendered HTML, the rendered PDF, page images, and other render artifacts for
download. These routes are off by default and only exist when the service
is started with --debug-enabled (DEBUG_ENABLED); with the flag unset the
routes 404, exactly as if they were never registered.
When enabled, /_debug carries no authentication of its own — it inherits
the same posture as the rest of the service, which is none (see above). Anyone
who can reach the port can render any template with data of their choosing and
download the result. Enable it only on a host nobody untrusted can reach: a
developer's own machine, or a network segment already restricted to people who
are allowed to see the documents this service renders. See
Debugging templates for the workflow the
flag is for.
Operator responsibilities (summary)
- Put an authenticating reverse proxy in front of the service; the in-app body-size and timeout defaults are a floor — set stricter limits and rate limiting there.
- Apply an egress network policy blocking private and link-local ranges
(e.g.
169.254.169.254, RFC-1918). This is the primary control for the browser's egress; the in-app SSRF guard only covers remote-template fetches. - Run the container sandboxed (seccomp, and ideally user namespaces / gVisor)
to compensate for
--no-sandbox, with CPU/memory limits. - Never feed untrusted data to raw-HTML sinks (
{{{triple}}}, themarkdown/embed_*helpers) or into URL/src/hrefpositions without validation. - Prefer building remote-template requests only from trusted data; the default private-address block is defense in depth, not a reason to feed it attacker-controlled URLs.
- Leave
--debug-enabledoff outside local development. If you need it on a shared or staging host, restrict reachability the same way you would for an unauthenticated admin endpoint — network policy, not the service.
Reporting
Please report security issues privately, not in a public issue. Open a confidential issue on the project's GitLab (tick "This issue is confidential") so the report stays visible only to the maintainers until a fix is available.