Guides · 2026-10-17
Your headless browser is an SSRF cannon: how we locked ours down
Any service that takes a URL from a user and opens it in a headless browser — screenshot APIs, link previewers, PDF generators, "import from URL" features — is a Server-Side Request Forgery machine by default. On the day we built Shotlane, our first test proved it: a single request returned a screenshot of our cloud provider's metadata service, complete with instance details and cloud-init configuration. Here's how that happened despite URL validation, and the fix that actually holds.
The naive defence: validate the URL
The first thing everyone writes is a check before navigation: parse the URL, resolve the hostname, and reject private addresses.
import ipaddress, socket
from urllib.parse import urlparse
def is_safe(url):
host = urlparse(url).hostname
ips = {i[4][0] for i in socket.getaddrinfo(host, None)}
return all(ipaddress.ip_address(ip).is_global for ip in ips)
This correctly blocks http://127.0.0.1, http://169.254.169.254, http://10.0.0.5, http://0x7f000001 and friends. We had it. We also had a request interceptor (Playwright's page.route) running the same check on every subresource. It still wasn't enough.
Bypass 1: redirects
We asked our API to screenshot a public URL that simply responds with a 302 to http://169.254.169.254/…. The initial URL passes validation — it's a perfectly normal public host. Chromium then follows the redirect internally, and request interception isn't consulted for the redirected hop. The screenshot came back with the metadata document rendered in plain text.
You can't fix this by checking page.url after navigation either: by then the request has already been made, and for blind SSRF (hitting an internal admin endpoint that changes state) the damage is done.
Bypass 2 and 3: DNS rebinding and everything that isn't a navigation
Even with perfect redirect handling, your check and Chrome's connection resolve DNS separately. An attacker-controlled domain with a very short TTL can answer your validator with a public IP and Chrome a moment later with 127.0.0.1. And pages can open connections in many ways: iframes, WebSockets, fetch from page JavaScript, service workers, CSS imports, preloads. Each is another path you'd need to intercept correctly in the browser — a game of whack-a-mole you'll eventually lose.
The fix: enforce it below the browser
The robust approach is to stop asking the browser to behave and make it impossible at the network layer. On Linux, nftables (or iptables) can match outgoing packets by the Unix user that owns the socket. So:
- Run the rendering service (and therefore Chromium) as a dedicated, unprivileged user.
- Reject any new outbound connection from that user to private, loopback, link-local, CGNAT, multicast and your own public addresses.
- Allow established/related traffic, so the service can still answer incoming requests from your reverse proxy, and allow DNS to the local resolver.
table inet render_egress {
chain output {
type filter hook output priority 0; policy accept;
meta skuid != "renderer" accept
ct state established,related accept
ip daddr { 127.0.0.53 } meta l4proto { tcp, udp } th dport 53 accept
ip daddr { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16,
172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15,
224.0.0.0/3, YOUR.PUBLIC.IP } counter reject
ip6 daddr { ::/127, ::ffff:0:0/96, 64:ff9b::/96, fc00::/7, fe80::/10,
ff00::/8, YOUR:V6::/64 } counter reject
}
}
Because the kernel checks the actual destination of every connection, it doesn't matter whether the request came from a redirect, an iframe, a WebSocket or a rebinding DNS answer. Re-running our metadata redirect test now fails instantly with a connection error, and the rule's counter ticks up.
A few details that matter:
- Use
reject, notdrop. Dropping makes Chrome wait for its full timeout, tying up a render slot for 25+ seconds per malicious request — an easy denial of service. - Block IPv4-mapped IPv6 (
::ffff:0:0/96) and NAT64 (64:ff9b::/96), or attackers can spell127.0.0.1another way. - Block your own public IP too, or the browser can reach services bound to
0.0.0.0via the front door. - Load the rule before the service starts (a systemd oneshot unit ordered
Before=your app works well), so there's no unprotected window after a reboot. - Keep the application-level check anyway. It gives users a clear error message instantly, and defence in depth costs nothing. Just don't rely on it.
Containers and serverless
In Docker or Kubernetes, the same idea applies at a different layer: put the renderer in its own network namespace with an egress NetworkPolicy (or a firewall on the node) that denies private ranges, and make sure the cloud metadata endpoint is blocked or requires IMDSv2-style session tokens. On AWS Lambda, run in a VPC without routes to sensitive internal services.
Takeaway
If a browser can be pointed at arbitrary URLs, treat it as hostile code running inside your network. Validate URLs for good error messages, but enforce the boundary somewhere the browser can't negotiate with — the kernel.
This is how every render on Shotlane runs. If you'd rather not maintain it yourself, our screenshot and PDF API handles it for you.
Skip the browser babysitting
Shotlane runs hardened headless Chromium for you: screenshots and PDFs from one HTTP request, with cookie-banner hiding, ad blocking, signed embed URLs and free cache hits. Prepaid credits that never expire.
100 free renders · no credit card · key shown instantly