Guard an outbound fetch against SSRF

A webhook sender fetches whatever URL the user gives it, and follows redirects. An attacker can point it at the cloud metadata service or an internal admin page, directly or through a redirect. Repair the guard so it resolves each host and refuses private addresses, in literals, encodings and redirects alike.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit fetch-policy.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the reach inside
Run the starter. The metadata case fetches http://169.254.169.254/… and the redirect case follows a 302 from a public host to http://127.0.0.1/admin, because the guard checks nothing.

2. Validate the URL and the scheme
Parse rawUrl with URL.parse (or new URL in a try). Reject a parse failure, a scheme other than http: or https:, and any username or password. Do not block on the raw text: the parser already normalizes 2130706433 and 0x7f.1 to 127.0.0.1.

3. Classify the addresses, not the name
For an IP-literal host use that address; for a name, await client.resolve(hostname) and block when it returns no address. Block when any address is loopback, private, link-local, carrier-grade NAT, unique-local, unspecified, multicast, a documentation or benchmarking address, or an IPv4-mapped IPv6 address. Check every returned address, not just the first.

4. Re-check every redirect
When client.fetch returns {redirect}, resolve it against the current URL and apply the whole policy again before following it; stop after 5 redirects. A first public hop does not vouch for where it sends you. Fetch a safe public URL and its safe redirects as before.

SUPPLIED CONTRACT
fetchGuarded(rawUrl, client) is async. It returns {status: "fetched", url} with the final URL string, or {status: "blocked"} (a reason is optional). client.resolve(hostname) resolves a name to an array of IP address strings (an empty array means the name does not resolve); an IP-literal host is not resolved. client.fetch(url) performs one hop and returns {redirect: url} (status 301/302/307/308 with a Location) or {ok: true} for a final response; it does not enforce any policy. Allow only http: and https:; reject credentials in the URL. Before each fetch, including every redirect target, determine the addresses the host would use — the literal itself for an IP host, or client.resolve(hostname) for a name — and block the request unless every address is a public unicast address. Private, loopback, link-local (including 169.254.169.254), carrier-grade NAT, multicast, unspecified, unique-local and IPv4-mapped IPv6 addresses are not public, and neither are the documentation and benchmarking ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 198.18.0.0/15 and 2001:db8::/32), which the IANA special-purpose registries mark as not globally reachable. For IPv6, treat only global unicast (2000::/3) as public. Resolve a relative redirect against the URL it came from, and follow at most 5 redirects.

REFERENCE
reference.mjs contains an explained solution: Named public hosts (api.partner.test → 140.82.121.3) are fetched; their safe public redirects are followed. The metadata address 169.254.169.254, and 2130706433, 0x7f000001, 127.1 and [::1] after the parser normalizes them, are blocked because the single literal address is link-local or loopback. A name that resolves to 10.0.0.5, to [::ffff:127.0.0.1] or to the documentation address 198.51.100.7 is blocked, as is a name that returns two addresses where only one is private, because every address must be public. A 302 from a public host to http://127.0.0.1/admin is blocked at the second hop; a relative redirect is resolved against the hop it came from, and a chain of exactly five redirects is followed while a sixth is not. Only a whole dotted quad is an IPv4 literal, so 93.184.216.34.partner.test is resolved like any name. The first and last address of every reserved range are blocked and their neighbors are fetched; for IPv6 only the first group decides, so 2606:4700::2001:db8:1 is public while 2001:db8::1 and fd12:3456::1 are not. gopher://, file:// and a URL with credentials are blocked before any fetch. A name that does not resolve is blocked. The policy still cannot stop DNS rebinding on its own: resolve returns the address used at check time, and a real client must connect to that same address (connect-to-IP or a pinned lookup), because a name can resolve to a public address now and a private one when the socket opens.

SCOPE
client.resolve and client.fetch are deterministic fixtures: resolve returns fixed addresses and fetch returns a scripted redirect or a final response, so the lab does not perform real DNS or network I/O. It checks the policy and the redirect loop, not a real HTTP client: production also needs connect-to-IP (or a resolver whose address is reused for the connection) against DNS rebinding, a timeout and response-size limit, and a deny of unexpected ports and of IPv6 as well as IPv4; the guide’s full deny list also covers the IPv6 prefixes that embed IPv4 addresses (NAT64, 6to4 and Teredo). Prefer an allowlist of destinations where the feature permits one.

NEXT TASK
In your own outbound-fetch code, where is the address the policy checks and the address the socket connects to the same lookup, and how are redirects handled?

SOURCE
https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
