SSRF guard (fetchGuard)
Think of it like…a corporate firewall on your office laptop. You can still browse the public internet, but the firewall won't let you dial the building's admin console at10.0.0.5: even if a phishing email tells you to. SSRF is the exact same trick aimed at your server: an attacker gives your code a URL, hoping it'll quietly fetch your own internal admin panel or the cloud provider's metadata endpoint.fetchGuard()is the firewall.
Any handler that calls fetch()on a URL the user can influence (an avatar fetch, a webhook delivery, an “import from URL” feature, an OAuth discovery endpoint, an embed unfurler) is a Server-Side Request Forgery (SSRF) sink. The canonical exploit is the Aikido write-up in which a contact form that emailed an avatar was redirected to http://169.254.169.254/, the AWS cloud metadata service, which handed back short-lived IAM credentials and pivoted into the startup’s S3 buckets.
Key Recommendation for SSRF guard (fetchGuard)
Use fetchGuard to wrap outbound fetch calls that request user-controlled, dynamic URLs (such as avatar URLs, webhook endpoints, or url imports). Never wrap requests going to hardcoded, static internal services or known, trusted third-party APIs.
When & Where to Use
- ✓Fetching resources (avatars, images, attachments) directly from user-supplied URLs.
- ✓Calling user-configured webhooks or callbacks from your application backend.
- ✓Parsing arbitrary links or URLs for rich embed previews ('unfurling').
When & Where NOT to Use
- ✗Making static outbound API requests to known, trusted external services (e.g. Stripe, SendGrid) where URLs are entirely controlled by your code.
- ✗Communicating with internal microservices, databases, or mesh endpoints (where private IP spaces like 10.0.0.x are expected and must be accessible).
- ✗High-performance proxying where you explicitly intend to relay traffic to arbitrary destinations and DNS resolution is cached separately.
- urlCheck protocolhttp: / https: only
- dnsResolve hostnameto one or more IPs
- ipMatch deny rangesRFC1918, loopback, 169.254.x
- safeDispatch requestredirects re-validated per hop
fetchGuard() wraps the global fetch and refuses to dispatch a request whose target resolves to a dangerous internal address, including every documented cloud metadata IP (AWS / Azure / DigitalOcean 169.254.169.254, Oracle Cloud 192.0.0.192, Alibaba 100.100.100.200).
Quick start
What gets blocked by default
- Loopback:
127.0.0.0/8,::1. Opt in withallowLoopback: truefor local-dev fixtures. - RFC1918 private:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. Opt in withallowPrivate: true. - Link-local (covers every cloud-metadata IP):
169.254.0.0/16,fe80::/10. Opt in withallowLinkLocal: true. - IPv6 unique-local:
fc00::/7. Opt in withallowUniqueLocal: true. - Always-deny floor (no flag lifts these):
0.0.0.0/8,100.64.0.0/10(CGNAT, Alibaba metadata),192.0.0.0/24(Oracle Cloud metadata), all IANA-reservedTEST-NET/ benchmarking / docs ranges,224.0.0.0/4multicast,240.0.0.0/4reserved, broadcast255.255.255.255, IPv6::/128andff00::/8. - Protocols other than
http:/https:(file:,data:,gopher:,ftp:,dict:,ldap:).
IPv4-mapped IPv6 (::ffff:a.b.c.d) is re-checked against the embedded IPv4 address, so http://[::ffff:169.254.169.254]/ is rejected the same way as http://169.254.169.254/.
Redirects are re-validated at every hop
A common SSRF bypass is to return 302 Location: http://169.254.169.254/ from a public host. fetchGuard() follows redirects manually: it re-checks the protocol and re-resolves DNS for every Location header before issuing the next request. Set maxRedirects: 0 to return the 3xx directly, or pass redirect: "manual" per call for the same effect.
Custom allowlists
Custom DNS resolution (non-Node runtimes)
The default resolver uses Node’s node:dns/promises.lookup(). On Cloudflare Workers, Deno without --allow-net, or any runtime without Node-style DNS, supply a resolver:
DNS pinning (pinDns)
On Node-like runtimes, fetchGuard() defaults pinDns: true when you do not supply a custom fetch. For http: requests the socket is then opened through node:http against the exact IP that passed validation, while the original Host header is preserved. That closes the classic DNS-rebinding (TOCTOU) window for the highest value target: cloud metadata at http://169.254.169.254.
https: is intentionally not pinned by this knob (TLS SNI / certificate validation needs the hostname path). Pass pinDns: false on Workers and other edge runtimes only if you had forced it on; the default is already off when process.versions.node is absent.
Residual risk: DNS rebinding (TOCTOU)
After pinDns, the remaining residual is mainly https: rebinding and non-Node runtimes without a pin path. Close those with operator egress controls, and optionally a custom undici dispatcher for TLS upstreams:
- Operator-side (recommended). Run behind a network policy that already blocks egress to RFC1918 / metadata IPs: Kubernetes
NetworkPolicy,step-security/harden-runnerin CI,iptables -A OUTPUT -d 169.254.169.254 -j DROPon the host. This neutralises rebinding even if the app is naive. - Caller-side, Node-only, for
https:. Daloy ships zero runtime dependencies, so we do not bundleundici. If you install it yourself, you can pin the TLS socket to the IP you validated by plumbing a custom dispatcher through the existingfetchoption:The socket connects to the pre-resolved IP; TLS SNI and certificate validation still use the original hostname.
fetchGuard() remains defense-in-depth on top of these controls.
Error shape
Blocked requests throw SsrfBlockedError with a structured reason:
protocol-not-allowed: URL wasfile:,data:, etc.address-not-allowed: resolved IP fell in a blocked range.dns-resolution-failed: lookup threw or returned no records.too-many-redirects: chain exceededmaxRedirects.credentials-in-url: the URL carried userinfo (http://user@host/), a classic SSRF obfuscation. The credentials are stripped from the URL recorded on the error.invalid-url: URL or Location header could not be parsed.
Network failures from the underlying fetch(DNS timeouts, TLS errors, connection refused) bubble through unchanged so your retry logic can distinguish “Daloy refused” from “the upstream is sad.”