SSRF in Screenshot Services: Definition and Defenses

SSRF tricks a server into fetching internal addresses. Why a screenshot service is a prime target, what a guard must check, and a tested Puppeteer guard.

By Simon Chiu · Updated

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

Attackerurl=http://… your private network Screenshot endpointheadless browser 169.254.169.254 metadata 10.0.0.5 admin panel localhost services PNG of the response
The browser sits inside the trust boundary, so any address it can reach becomes visible to whoever controls the URL.

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> or fetch(), 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:

  1. Before launch: scheme is http or https, no credentials in the URL, port 80 or 443, and every address the host resolves to is public.
  2. On every request, including redirects and subresources, with the same check.
  3. 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') returned false without 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.
  • 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.

Frequently asked questions

Why are screenshot services especially exposed to SSRF?
Fetching a URL that a user supplies is their whole job, and a real browser follows redirects, loads subresources and runs scripts that make more requests. Each of those is a chance to reach an internal address.
Is checking the hostname before navigation enough?
No. A public name can resolve to a private address, DNS can change between your check and the browser's own lookup, and a public page can redirect or load an image from a private address. Check every request, and check the address the browser actually connected to.
Can RenderScreenshot capture my localhost or intranet pages?
No. The renderer refuses private, loopback and link-local targets on purpose. Send the HTML in the html parameter instead, or expose the page on a public URL.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.