A screenshot API is a request forgery engine you rent out on purpose

SSRF when strangers choose the URL - private ranges in both IPv6 spellings, re-checking every redirect hop, the cloud metadata address, signed URLs so a key can live in public HTML, and the DNS rebinding gap I have not closed.

A screenshot API takes a URL from a stranger, fetches it from inside your infrastructure, and hands the result back. That is server-side request forgery as a product. The only thing separating it from a vulnerability is the checking.

If you self-host Screenshotline, or build anything that fetches a URL someone else supplied (a webhook tester, a link preview, an importer, an RSS reader), this is the part worth getting right. Below is what the code actually does, including the gap it doesn't close.

The obvious attack, and why one check isn't enough

The first thing anyone tries is this:

/take?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/

That address is the cloud metadata service. On many providers it answers to anything running on the instance, with no authentication, and it hands out credentials. A screenshot of it is a screenshot of your keys.

So private and reserved ranges are refused: loopback, 10/8, 172.16/12, 192.168/16, link-local 169.254/16 where metadata lives, and the IPv6 equivalents. Hostnames like localhost, anything ending .localhost or .internal, URLs with embedded credentials, and any scheme that isn't http or https are refused too, because a page has no business fetching file:// through us.

But a single check at submit time is not enough, for three reasons.

Redirects. https://totally-fine.example/ can answer 302 to http://169.254.169.254/. So the check runs again on every redirect hop, not just the submitted URL. A short-lived DNS cache keeps a redirect chain from triggering a lookup per hop.

Subresources. The page itself can ask for anything: an <img>, a fetch(), an iframe. So there is a second, synchronous check on the request interception path that refuses literal private addresses for every subresource a page pulls. It does no DNS on purpose, because a lookup per subresource would be a performance disaster on an image-heavy page.

Names that resolve inward. A hostname is resolved, and every address it returns is checked, not just the first. A host with both a public A record and a private one isn't a pass.

The bug: one address, two spellings

http://[::ffff:10.0.0.1]/

That's an IPv4-mapped IPv6 address: a way of writing 10.0.0.1 in IPv6 form. My private-range check for IPv6 matched that dotted spelling and refused it.

Except the URL never arrives in that spelling. The WHATWG URL parser, which is what new URL() uses in Node and in every browser, rewrites it to ::ffff:a00:1, the same address in hex. My check matched the human spelling and never saw the hex one, so http://[::ffff:a00:1]/ sailed through the guard and routed to 10.0.0.1.

The fix is to match both:

const dotted = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (dotted) return isPrivateV4(dotted[1]);
const hex = a.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
  const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
  return isPrivateV4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
}

Validate the normalised form, not the form you expect to receive. Anything between the attacker and your check, whether a URL parser, a proxy or a framework, may rewrite the input. If your test suite only ever feeds it the spelling a human would type, it tests the wrong string.

The related default matters too. The address-parsing function ends:

return true; // not an IP we understand - refuse rather than guess

An address form the checker doesn't recognise is treated as private. Refusing a few exotic-but-legitimate addresses is a support ticket. Allowing one unparsed address is an incident.

The gap I have not closed: DNS rebinding

Being honest about this is better than quietly hoping nobody notices.

The renderer resolves the hostname to check it, and then Chrome resolves the hostname again when it fetches. Two lookups. A DNS record engineered to change between them, a very short TTL with the public address first and the private address second, can get past the check.

Literal private IPs are still refused for every subresource, so the attack needs a hostname rather than an address. But a hostname that resolves into a private range is only checked at navigation time.

Closing it properly means pinning the resolved address and forcing Chrome to use that exact address, via --host-resolver-rules. It is in SECURITY.md rather than left unsaid, and the honest mitigation today is deployment shape: run the renderer where there is nothing worth reaching. No route to your database, your internal services, or your metadata endpoint. Then a rebinding win lands somewhere boring.

That is also why ALLOW_PRIVATE_HOSTS=true, the flag that turns all of this off, should only ever be on a box that is not reachable from the internet. If your reason for self-hosting is screenshotting internal dashboards, that is the flag, and network isolation becomes the whole of your security.

The other direction: your key in public HTML

The headline use case for returning image bytes directly is embedding a capture in a page:

<img src="https://api.screenshotline.com/take?url=https://example.com&access_key=...">

The moment you do that, your key is in view-source for anyone who looks, and your quota belongs to them.

Signed URLs fix it. Set a signing secret, and the server accepts a request carrying an HMAC-SHA256 over its own sorted query parameters:

export function canonicalQuery(params) {
  return Object.keys(params)
    .filter((k) => k !== 'signature')
    .sort()
    .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`)
    .join('&');
}

Sorting matters: it means the signature covers the parameters as a set, not as the order someone happened to write them. Changing any of them, the target URL, the viewport or the format, invalidates it. The comparison is constant-time, and REQUIRE_SIGNATURE=true refuses unsigned requests entirely, so a leaked key alone buys nothing.

One detail that is easy to get wrong: the cache key deliberately excludes access_key and signature. Two customers requesting the same page with the same options should share a cache entry, and if the credential were part of the key, it would also make cache behaviour depend on who asked, which is how cross-tenant cache bugs start.

The short version

If you fetch URLs that strangers choose:

  1. Refuse private and reserved ranges, in every spelling, and refuse addresses you cannot parse.
  2. Re-check on every redirect hop, not just the submitted URL.
  3. Check subresources too. The page fetches things you didn't ask for.
  4. Run it somewhere boring, with no route to anything you care about.
  5. Write down what you haven't fixed. Mine is DNS rebinding.
  6. If the URL will be public, sign it rather than putting a key in it.

The whole guard is one file, src/security.js, and the signing is in src/auth.js. Both are short enough to read in a sitting.

Get a free key — 500 renders a month Try it without signing up

More from the blog