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:
- Refuse private and reserved ranges, in every spelling, and refuse addresses you cannot parse.
- Re-check on every redirect hop, not just the submitted URL.
- Check subresources too. The page fetches things you didn't ask for.
- Run it somewhere boring, with no route to anything you care about.
- Write down what you haven't fixed. Mine is DNS rebinding.
- 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.