ENGINEERING
How I built SSRF protection for a screenshot API
A screenshot API is, from a security standpoint, an alarming thing to run. The entire product is: you hand my server a URL, and my server fetches it. That is the textbook definition of a Server-Side Request Forgery (SSRF) primitive — I've built a machine whose whole job is to make outbound requests to wherever a stranger points it.
Get it wrong and an attacker doesn't screenshot example.com. They screenshot http://169.254.169.254/latest/meta-data/iam/security-credentials/ and read your cloud credentials out of the returned image. Or they hit an internal admin panel that trusts anything on the private network. So before ogshot rendered a single public page, the fetch path had to be locked down. Here's what that actually took.
Why "just block localhost" fails immediately
The naive first instinct is a denylist: reject localhost and 127.0.0.1. This is worthless, and it's worth understanding exactly why, because each failure points at a layer you actually need.
- Hostnames hide IPs.
localtest.meand thousands of other public hostnames resolve to127.0.0.1. An attacker can point DNS they control at any internal address. You can't filter on the string; you have to resolve it and filter on the answer. - There are a lot of private ranges. Not just loopback — RFC1918 (
10/8,172.16/12,192.168/16), link-local (169.254/16, home of the cloud metadata endpoint), CGNAT (100.64/10), and a pile of reserved blocks. - IPs can be obfuscated.
http://127.0.0.1is alsohttp://2130706433(decimal),http://0x7f000001(hex), andhttp://[::ffff:127.0.0.1](IPv4-mapped IPv6). A string denylist sees none of these. - Redirects and subresources bypass the front door. You validate the top-level URL, it returns a
302to169.254.169.254, and the browser follows it. Or the page loads an<img>from an internal host. Checking only the URL the user typed guards nothing.
So the real design is a positive model — allow only what is provably a public address — enforced at every point where a request can originate, not just the first one.
Layer 1: resolve, then require a public address
The core check throws unless a URL is http(s) and every address its host resolves to is a globally-routable unicast address. I lean on ipaddr.js for range classification instead of hand-rolling CIDR math:
Two details do a lot of quiet work here. ipaddr.process() collapses IPv4-mapped IPv6 down to its IPv4 form before classifying, so ::ffff:10.0.0.5 can't sneak past as "some IPv6 address." And range() returning anything other than "unicast" — loopback, private, linkLocal, reserved, carrierGradeNat, and friends — is a rejection, so I'm allowlisting the one good category rather than trying to enumerate every bad one.
The obfuscated-IP cases mostly solve themselves, because I parse with the WHATWG URL class, which canonicalizes them: new URL("http://0x7f000001").hostname is already "127.0.0.1" by the time my check runs. Letting the standard library normalize the host before you inspect it is the move.
Layer 2: guard every request inside the browser
Layer 1 protects the top-level navigation. It does nothing about the redirect that URL returns, the tracking pixel it loads, the iframe it embeds, or the popup it opens. Those all originate inside Chromium, after my pre-check has run.
The fix is to intercept at the browser-context level — so it covers every page, frame, and popup — and run the same public-address check on every single request:
One easy thing to miss: service workers. A service worker can issue fetches that don't pass through the route handler at all, which is a clean bypass of everything above. Playwright lets you turn them off at the context level, so I do:
Layer 3: DNS rebinding, and the check most people skip
Here's the subtle one. My pre-check resolves the hostname and sees a public IP. Then it hands the URL to Chromium, which does its own DNS resolution to actually open the socket. Those are two separate lookups. An attacker who controls the nameserver can answer "public IP" to my resolver and, a moment later, "127.0.0.1" to the browser's. That's DNS rebinding, and it defeats every check so far because every check ran against the honest answer.
You can't fully close this from inside the app without controlling the socket — the real fix is a resolve-once-and-pin egress proxy. But you can take away the attacker's prize. Playwright exposes the IP the browser actually connected to, per response, via serverAddr(). So I watch every response and, if any connection landed on a non-public address, I refuse to return the screenshot at all:
The rebinding attack still connects — I can't stop that here — but the attacker never gets the rendered bytes back, which is the whole point of an SSRF read primitive. One small gotcha that cost me a real bug: serverAddr() hands back IPv6 in brackets ([2606:4700:...]), and my parser choked on the brackets, so it briefly flagged every Cloudflare-fronted site — i.e. most of the web — as an attack. A live smoke test caught it; my unit tests hadn't. Strip the brackets before you classify.
What this still doesn't cover
Being honest about the edges is part of the design, not an afterthought:
- The rebinding TOCTOU window is still open in the sense that a connection to an internal host is made before I reject it. If an internal endpoint performs a state change on a plain
GET, that side effect happens. The read is blocked; a blind write is not. The real fix is an egress proxy that resolves once and pins the connection to that IP — on the roadmap, not shipped. - This is network-layer SSRF defense only. It doesn't address application-layer abuse like rendering a page that itself exfiltrates data, or resource exhaustion from a deliberately heavy page (that's what timeouts, concurrency caps, and rate limits are for).
None of this is exotic. It's four boring layers — resolve-and-classify, guard every in-browser request, block service workers, verify the connected IP — stacked so that no single bypass gets through all of them. That's usually what security looks like: not one clever trick, but a short list of unglamorous checks applied at every boundary, plus the honesty to document the gap you haven't closed yet.
ogshot is a rendering API that turns a URL or HTML into an image — social cards, OG images, and screenshots — with the guard above on every request. The playground lets you try it, and the docs spell out the security model. If you see a hole in the approach, I'd genuinely like to hear it.