Server-side request forgery (SSRF) is an attack in which someone makes your server send a request to an address they choose, usually one only your server can reach, such as 127.0.0.1, a private network or the cloud metadata endpoint 169.254.169.254.
A screenshot service is SSRF by design: it takes a URL from a stranger and opens it in a browser that runs inside your network. Unless the service checks where every request goes, "screenshot this URL" becomes "show me your internal admin panel". This entry is part of the screenshot and rendering glossary.
A plain explanation
Your laptop cannot reach a company's database dashboard, but the company's own servers can. If one of those servers will fetch any URL you give it and show you the result, you can borrow its network position. With a screenshot service you even get a picture of the response.
We tested the unguarded version: a plain Puppeteer page.goto() on a URL from input, pointed at a small server on 127.0.0.1 that stood in for an internal admin page. It rendered the page, including the fake db_password= line, without complaint. Nothing in a headless browser stops this by default.
How the attack reaches the network
A browser widens the attack surface compared with a plain HTTP fetch. The first URL can be public and harmless, then:
- redirect to a private address;
- load a subresource, such as an
<img>orfetch(), from one; - resolve differently the second time. With DNS rebinding, a name returns a public IP to your check and a private IP to the browser's lookup a moment later.
Names are also slippery. In our test, 127.0.0.1.nip.io resolved to 127.0.0.1, http://2130706433/ is 127.0.0.1 written as one decimal number, and [::ffff:127.0.0.1] is the same address in IPv6 form. A string blocklist misses all three.
How a guard works
A sound guard checks three times:
- Before launch: scheme is
httporhttps, no credentials in the URL, port 80 or 443, and every address the host resolves to is public. - On every request, including redirects and subresources, with the same check.
- On every response, against the IP the browser actually connected to. This is the step that defeats DNS rebinding.
Here is that guard for Puppeteer, using Node's BlockList:
// ssrf-guard.mjs import { lookup } from 'node:dns/promises'; import { BlockList, isIP } from 'node:net'; import puppeteer from 'puppeteer'; const deny = new BlockList(); for (const [net, bits] of [ ['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.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3], ]) deny.addSubnet(net, bits, 'ipv4'); for (const [net, bits] of [['::', 127], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) deny.addSubnet(net, bits, 'ipv6'); // BlockList also matches IPv4-mapped IPv6 addresses (::ffff:7f00:1) against the IPv4 rules. const isPrivate = (ip) => deny.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4'); export async function checkUrl(raw) { const url = new URL(raw); if (!['http:', 'https:'].includes(url.protocol)) return `scheme ${url.protocol}`; if (url.username || url.password) return 'credentials in URL'; if (url.port && !['80', '443'].includes(url.port)) return `port ${url.port}`; const host = url.hostname.replace(/^\[|\]$/g, ''); const addrs = isIP(host) ? [{ address: host }] : await lookup(host, { all: true }); const bad = addrs.find((a) => isPrivate(a.address)); return bad ? `${host} -> ${bad.address}` : null; } export async function safeScreenshot(target, path) { const reason = await checkUrl(target); if (reason) throw new Error(`refused before launch: ${reason}`); const browser = await puppeteer.launch(); try { const page = await browser.newPage(); let violation = null; await page.setRequestInterception(true); page.on('request', async (req) => { const why = req.url().startsWith('data:') ? null : await checkUrl(req.url()).catch((e) => e.message); if (why) { violation ??= `request ${why}`; return req.abort('blockedbyclient'); } return req.continue(); }); page.on('response', (res) => { const ip = res.remoteAddress()?.ip?.replace(/^\[|\]$/g, ''); // IPv6 comes back as [2606:...] if (ip && isPrivate(ip)) violation ??= `connected to ${ip}`; }); await page.goto(target, { waitUntil: 'load', timeout: 15_000 }); if (violation) throw new Error(`refused after load: ${violation}`); await page.screenshot({ path }); } finally { await browser.close(); } }
Tested on 2026-10-15 with Node 22.18 and Puppeteer 24.43.1 (headless shell mode, Chrome 148). https://example.com was captured. Refused before launch: 127.0.0.1, 169.254.169.254, [::1], [::ffff:127.0.0.1], [::ffff:a9fe:a9fe], [fd00::1], localhost, 127.0.0.1.nip.io, 2130706433, port 8443, user:pw@ and file://. A separate run loaded example.com and then requested an image from 169.254.169.254; the request hook aborted it.
Playwright has the same building blocks: page.route('**/*', ...) to check and abort each request, and await response.serverAddr() for the connected IP. route does not see every request a service worker makes, so block service workers with serviceWorkers: 'block' on the context.
The guard is a minimum, not a full sandbox. Run the browser on a network segment with no route to internal systems, so a bug in the check is not a breach.
SSRF and RenderScreenshot
The RenderScreenshot renderer applies these checks on its side: private, loopback, link-local and reserved ranges, internal host names, ports other than 80 and 443, and credentials in URLs are refused, and every response address is checked. A refused target returns a 422 error with no image and no credit charged. The blocked error page lists each message.
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "http://169.254.169.254/latest/meta-data/" }' # 422, error.type "target_error", message starting "blocked_url:"
That protects our network, not yours. If your app has a /preview?url= endpoint that calls the API, it can still be an open proxy for your credits, and any fetch your own server makes is unguarded. The security best practices guide has a tested allowlist for that endpoint. This curl call was not run for this page; the response shape comes from the error docs.
Common mistakes
- Checking the string, not the address. Decimal IPs, IPv6 forms and wildcard DNS names defeat any regex.
- Checking once. Redirects, subresources and DNS rebinding all happen after the first check.
- Reading
remoteAddress()raw. Puppeteer returns IPv6 in brackets. In our test,BlockList.check('[::1]', 'ipv6')returnedfalsewithout an error, so a bracketed private address slipped through until we stripped the brackets. - Forgetting non-HTTP schemes.
file://reads local files in a browser.
Related terms
- Headless browser: the component that makes the requests.
- Signed URL: how to expose a capture link without exposing a free-for-all endpoint.
- Bot detection: the other side, sites that refuse automated visitors.
To capture public pages without running a browser inside your own network, get a free API key.