Any feature where your server fetches a URL that a user supplied has the same shape of vulnerability: server-side request forgery. Webhook targets, "test this endpoint" buttons, URL-based imports, link-preview generators — all of them take a string from an untrusted user and hand it to fetch() running on infrastructure that has network access the user doesn't. Point that string at http://169.254.169.254/latest/meta-data/ (the cloud metadata endpoint most providers expose) or at http://localhost:9200 (an internal service that trusts requests from its own network), and your server just became the attacker's proxy into places they couldn't otherwise reach.
Flatline has exactly this shape: a customer registers a check and gives us a webhook_url, and when their job goes silent, we POST an alert to it. Here's what actually closing that hole looked like, and the one part of it we're honest about not closing.
Two separate places the check has to happen
It's tempting to think of this as one validation step. It's actually two, at two different times, and skipping either one leaves the hole open:
- Config time — when the URL is saved, reject anything that's already an obviously internal target.
- Request time — when the URL is actually fetched, don't blindly follow redirects.
The second one is easy to miss. A URL can pass config-time validation as a completely ordinary public hostname, then 30x to an internal address at the moment you actually fetch it — bypassing every check you did when it was saved.
Config time: rejecting IP literals isn't a string comparison
The naive version of this check is if (url.includes('192.168')) reject(). That fails immediately, because IPv4 addresses have more than one valid spelling. 127.0.0.1, 2130706433 (the same address as a plain decimal integer), and 0177.0.0.1 (octal) all resolve to the same loopback address, and a naive string check catches exactly one of the three.
The fix is to let a real URL parser do the normalizing for you first, then check the canonical form it produces:
const parsed = new URL(rawWebhook);
if (isPrivateOrLoopbackHostname(parsed.hostname)) {
return { ok: false, error: 'webhook_url must not point to a private, loopback, or link-local address' };
}
The WHATWG URL parser (built into every JS runtime, including Workers) already canonicalizes decimal/octal/hex IPv4 literals into standard dotted-quad form, and compresses/brackets IPv6 literals — so the check only needs to recognize the handful of canonical private ranges, not every obscure way a string can spell an IP address:
127.0.0.0/8— loopback10.0.0.0/8,172.16.0.0/12,192.168.0.0/16— RFC 1918 private ranges169.254.0.0/16— link-local, which is also where cloud metadata endpoints live (169.254.169.254on AWS, GCP, and Azure alike)::1,fe80::/10,fc00::/7— the IPv6 equivalents::ffff:0:0/96— IPv4-mapped IPv6, unwrapped and re-checked against the IPv4 ranges abovelocalhostand*.localhostas literal hostnames
An IPv6 literal that's bracketed but doesn't parse into 8 valid groups gets rejected too — fail closed on anything malformed rather than let an unrecognized-but-URL-legal literal slip through.
Request time: redirect: 'manual'
This is the part the config-time check can't cover. A webhook URL can be a completely legitimate public host at save time and still redirect to an internal target the moment it's actually fetched — either because the attacker controls that endpoint and changes its behavior after the check passes, or because it was never malicious and just happens to redirect somewhere your validation never saw.
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
redirect: 'manual',
});
redirect: 'manual' stops the runtime from transparently following a 30x — instead of silently landing on whatever the redirect points to, the response comes back as res.ok === false with an opaque redirect type, which the delivery code already treats as a failed attempt. No special-casing needed; the default "unknown response, not a success" path already does the right thing.
What this doesn't cover: DNS rebinding
We'd rather say this plainly than let the checklist above read as a complete defense. DNS rebinding defeats hostname-based validation entirely: an attacker registers a domain whose DNS record points to a normal public IP when your validation step resolves it, then changes the record to point at 127.0.0.1 or an internal address between the check and the actual fetch. The hostname never looks like anything but an ordinary domain — there's no IP literal for the check above to catch.
Closing that gap requires resolving the hostname once, validating that IP, and then connecting to the validated IP directly rather than letting the runtime re-resolve the hostname at fetch time (or routing the request through a proxy that does the same pinning). That's a meaningfully bigger lift — it means controlling the DNS-resolution-to-connection path, which isn't something a Workers fetch() call gives you a hook into. We scoped it out of this pass deliberately rather than pretend the IP-literal check handles it. If you're building this for a target where DNS rebinding is a realistic threat (anything accepting webhook URLs from untrusted third parties at scale), budget for a proxy-based resolution step — don't stop at hostname validation and call it done.
The checklist, if you're adding this to your own feature
- Parse the URL with a real parser, don't regex or substring-match it.
- Reject anything that isn't
http:orhttps:. - Reject IP-literal loopback, private, and link-local ranges (both IPv4 and IPv6, including the IPv4-mapped IPv6 form) and the
localhosthostname — at config/save time. - Set
redirect: 'manual'on the actual outbound fetch, and treat a redirect response as a failure, not a success — at request time. - Put a timeout on the fetch (an
AbortControlleris enough) so a target that never responds can't tie up the request indefinitely. - Know — and document — that none of the above stops DNS rebinding. That needs IP-pinning or a validating proxy, which is a bigger project than a validation function.
This is defense-in-depth against the common case (an IP-literal target), stated honestly as not a complete SSRF solution. If your threat model includes DNS rebinding specifically, the checklist above is a floor, not a ceiling.
Read the actual validation code or self-host it.