DEV Community

Mike
Mike

Posted on

Your URL-fetching service is an SSRF engine - here's the two-layer guard we shipped

If your service takes a URL from a user and fetches it server-side - a monitor, a link previewer, a webhook tester, an "import from URL" button - you've built a server-side request forgery (SSRF) engine whether you meant to or not. NorthDuty is a website health monitor: you give it a URL, we load it in a real Chromium browser and report back. That is exactly the shape of the problem.

The attack

Our server has a network position your users don't. It runs in a VPC. It can reach the cloud metadata endpoint at 169.254.169.254. It can reach internal services on 10.x and 192.168.x that never touch the public internet.

So an attacker signs up and adds a monitor for:

http://169.254.169.254/latest/meta-data/iam/security-credentials/
Enter fullscreen mode Exit fullscreen mode

If we naively fetch that and hand back the response — or even just its status and timing — they've pulled temporary IAM credentials for the role running the fetch. The same trick reaches internal admin panels, databases with HTTP interfaces, and localhost services bound only to loopback.

Why the obvious fix falls short

The first instinct is a blocklist on the hostname string: reject localhost, 127.0.0.1, maybe 169.254.169.254. Two things break it.

It's ranges, not addresses. Loopback isn't one IP, it's 127.0.0.0/8. Private space is three CIDR blocks. Add carrier-grade NAT (100.64.0.0/10), link-local (169.254.0.0/16, which contains the metadata IP), and every IPv6 equivalent. And ::ffff:127.0.0.1 is loopback written as an IPv4-mapped IPv6 address — a substring match on "127.0.0.1" won't catch ::ffff:7f00:1, which is the same address. You have to parse and classify the resolved IP, not pattern-match the text someone typed.

Time-of-check vs time-of-use. Even a correct IP classifier run once is beatable with DNS rebinding. The attacker controls evil.example.com. When your check resolves it, it answers with a public IP — pass. Milliseconds later, when the browser actually connects, the same hostname resolves to 10.0.0.1. Your check and your fetch saw different answers.

How we actually guard it

Two layers, and a request only proceeds if both allow it.

1. Pre-navigation. Before the browser touches the URL, resolve the hostname and classify the resolved IP against every blocked range. The IPv4 CIDRs we reject:

const IPV4_BLOCKED_CIDRS = [
  ['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], // + docs / multicast / reserved
];
Enter fullscreen mode Exit fullscreen mode

IPv6 gets expanded to its eight 16-bit hextets first — including any embedded IPv4 tail — so equivalent notations collapse to one canonical form before we classify them.

2. Per-request. A page isn't one fetch. It pulls in scripts, images, XHRs — dozens of URLs you never typed. So we attach a route handler in Playwright that re-runs the same IP check on every sub-resource hostname, caching per host. This is the DNS-rebinding defense: the check runs at connect time, not once upfront, so a hostname that flips to a private IP between resolution and use gets caught on use.

There's exactly one escape hatch: ALLOW_PRIVATE_URLS=true, which disables both layers for local development and nowhere else.

The part nobody warns you about

This guard is security-critical and it lives in two separate services — the screenshot worker and the user-flow worker, both driving Playwright. Copy-pasting a security control into two codebases is how you end up with two codebases that slowly disagree. One gets a fix for a new IPv6 edge case; the other doesn't; six months later one service is exploitable, the other isn't, and nobody remembers why.

Our answer is blunt: the shared core file has to stay byte-for-byte identical between the two repos. A CI job fetches the sibling repo's copy and fails the build on a single byte of difference. Not "logically equivalent" — identical. Fix one and CI stays red until you've copied it verbatim into the other. It's crude, but a security check that silently diverges across services is worse than one you deliberately maintain in two places.

Takeaway

If you fetch user-supplied URLs server-side, assume you're running an SSRF proxy until you've proven otherwise. Classify resolved IPs against ranges, not a handful of literal addresses. Check at connect time, not just upfront, or DNS rebinding walks straight through. And if the guard has to exist in more than one service, make divergence a build failure — not a code-review hope.

This is roughly why the SSRF guard was one of the first things we built into NorthDuty instead of one of the last.

Top comments (0)