URL-to-PDF and SSRF: how a PDF renderer becomes an attack surface
A renderer that fetches URLs can be pointed at your internal network. The attacks, the defences, and what to demand from a PDF API vendor.
A URL-to-PDF endpoint is a web browser you let strangers drive. If that browser sits inside your network, it can be asked to open http://169.254.169.254/ — cloud metadata, including credentials — or http://localhost:8080/admin, and print what it sees into a PDF the attacker then downloads.
This is server-side request forgery, and PDF renderers are among its most reliable hosts, because fetching arbitrary URLs is the advertised feature.
The attack surface
Direct. Submit an internal URL. The renderer is inside the perimeter; the attacker is not.
Redirect. Submit a public URL that responds 302 to an internal one. Validating only the URL you were given catches nothing.
DNS rebinding. A hostname that resolves to a public address when you validate it and to 127.0.0.1 a moment later when the browser connects. Time-of-check to time-of-use, in DNS form.
Subresources. Even with a validated page URL, the HTML can contain or an image pointing inward. If you render user-supplied HTML rather than a URL, this is the primary vector.
Non-HTTP schemes. file:///etc/passwd renders a local file into the PDF if the renderer permits it.
Defences that actually hold
Resolve, then check every hop. Validate the hostname's resolved addresses against private, loopback, link-local, reserved and multicast ranges — and repeat the check on every navigation, not just the first:
import ipaddress, socket
from urllib.parse import urlparse
BLOCKED_HOSTS = {"localhost", "metadata.google.internal", "169.254.169.254"}
def assert_public(url: str):
u = urlparse(url)
if u.scheme not in ("http", "https") or not u.hostname:
raise Unsafe("http(s) only")
host = u.hostname.lower()
if host in BLOCKED_HOSTS or host.endswith((".local", ".internal")):
raise Unsafe("blocked host")
for info in socket.getaddrinfo(host, None):
ip = ipaddress.ip_address(info[4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
raise Unsafe("resolves to a private range")
In Playwright, hook navigation so redirects are checked too:
page.route("**/*", lambda route: (
assert_public(route.request.url), route.continue_()
) if route.request.is_navigation_request() else route.continue_())
Isolate the network. The strongest control is architectural: run renderers on hosts with no route to internal services. Egress to the public internet, nothing else. Then a bypass of the URL check reaches nothing worth having.
Never give the renderer credentials. No shared cookies, no instance role with permissions, no access to your secrets manager. It should be the least privileged thing you run.
Cap time and size. A slow endpoint that trickles bytes forever ties up a worker; that is a denial of service even without data exfiltration. Thirty seconds and a page-size ceiling.
Treat user HTML as hostile. It is markup from the internet, executed in a browser you operate. Same network isolation applies.
What to ask a vendor
If you are buying rather than building, these questions separate serious implementations from checkbox ones:
- Do you validate private ranges on redirects and subresources, or only the submitted URL?
- Are render hosts network-isolated from your own infrastructure and metadata endpoints?
- Are
file://and other non-HTTP schemes blocked? - What are the timeout and size limits?
- Is rendered content isolated per customer — can one request see another's cookies or cache?
For PDFGeny specifically: every navigation request is checked against the private-range list, not just the first; file:// and non-HTTP schemes are refused; renders run in a fresh browser context per document on hosts that see only the public internet; and requests time out at 30 seconds.
You can verify the first one yourself:
curl -X POST https://pdfgeny.com/api/v1/render \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"url":"http://169.254.169.254/latest/meta-data/"}'
{"error":{"code":"render_failed","message":"Rendering failed: This host is not allowed.", …}}
The related risks
SSRF gets the attention, but two neighbours matter as much:
Resource exhaustion. A page with a while(true) loop or a 500 MB image will happily consume a worker. Timeouts, memory caps per render process, and a queue that degrades rather than collapses.
Data leakage between tenants. If document storage is not scoped per account, a predictable identifier lets anyone enumerate other people's files. Signed, expiring URLs with an HMAC — not sequential ids — are the fix.
A short checklist
- Private, loopback, link-local and metadata ranges refused — on every hop
- Non-HTTP schemes refused
- Render hosts with egress only
- No credentials on the rendering host
- Timeouts and size caps
- Per-document isolation, per-account storage scoping
- Signed, expiring download URLs