guarded-fetch
Drop-in fetch on the server for URLs you don't trust!
Server Side Request Forgery is when user modified urls let an attacker point your server at
http://169.254.169.254 and read your cloud credentials (Common cases are urls like webhooks,
log drains, connector
callbacks, etc.). guardedFetch is a
drop-in replacement that blocks that whole class of attack (SSRF), plus DNS
rebinding, redirect tricks, header smuggling, and unbounded responses.
Built on undici. Two runtime
dependencies (undici, ipaddr.js). Node ≥ 20.19.
Getting started
npm install guarded-fetchimport {
guardedFetch,
guardedFetchJson,
GuardedFetchError,
} from 'guarded-fetch';
// Just like fetch — returns a standard Response:
const response = await guardedFetch('https://example.com/api/data');
const data = await response.json();
// Or fetch + parse JSON with a size limit in one call:
const config = await guardedFetchJson<{ issuer: string }>(
'https://example.com/.well-known/openid-configuration',
);When a request is blocked or fails, a GuardedFetchError is thrown with a
stable code you can branch on:
try {
await guardedFetch(userSuppliedUrl, { method: 'POST', body: payload });
} catch (err) {
if (err instanceof GuardedFetchError && err.code === 'hostname_unsafe') {
// The URL resolved to a private/internal address — likely an SSRF probe.
}
throw err;
}Fetch-compatible SDK transport
Use createGuardedFetch() when an SDK accepts a standard fetch function and
owns request headers, redirect validation, and response limits:
import { createGuardedFetch } from 'guarded-fetch';
const fetch = createGuardedFetch();
try {
const response = await fetch('https://example.com/file', {
redirect: 'manual',
});
// Consume or cancel the response before closing the transport.
console.log(await response.text());
} finally {
await fetch.close();
}The factory accepts native Request, FormData, and normal fetch options,
preserves authentication headers and streaming bodies, and uses its own undici
fetch and connection pools independently of global fetch wrappers. Caller
aborts remain active while reading the response. Inline data: URLs are served
without DNS or a network connection.
Unlike guardedFetch, it leaves request policy to the caller:
| Behavior | guardedFetch | createGuardedFetch() |
|---|---|---|
| URL, IP-literal, and DNS checks | Yes (preflight + connect-time pin) | Yes (connect-time pin only) |
| Redirects | redirect option; 'follow' (default) re-validates every hop | Not followed — 'manual' or 'error' |
| Header sanitization | Yes | No |
| Timeout | timeoutMs (10 s default) | None — pass a signal |
| Response size limit | With the JSON/text wrappers | None |
opaqueErrors / onUrlBlocked | Yes | No (module-level setUrlBlockedHandler only) |
blockedEncodings | Yes | Yes (trusted origins too) |
| Error on a connect-time block | GuardedFetchError | TypeError: fetch failed, GuardedFetchError on .cause |
| Error on a refused redirect | GuardedFetchError redirect_not_allowed | TypeError: fetch failed, plain Error on .cause (no code) |
The default redirect: 'follow' is treated as 'error'. With
redirect: 'manual' the 3xx response is returned, and the caller must validate
each subsequent hop and strip credentials across origins — which is what the AI
SDK's redirect helpers do. For automatic redirect and header policies, use
guardedFetch instead.
For developer-controlled private services, configure exact origins:
const fetch = createGuardedFetch({
trustedOrigins: ['https://models.internal:8443'],
});Only those exact scheme/hostname/port combinations bypass IP/DNS checks. Paths
are ignored; every path on that origin is trusted. Never derive this setting
from users, model output, provider responses, or redirect targets. Reuse the
transport and call its idempotent close() after consuming all responses.
What's on by default
guarded-fetch has a high security bar by default. guardedFetch(url) call with no additional options prevents:
| Protection | Default behavior |
|---|---|
| Protocol allowlist | Only http: and https: URLs are accepted. |
| SSRF DNS check | The hostname is DNS-resolved before connecting; private, loopback, link-local, CGNAT, and cloud-metadata addresses are rejected. localhost and *.local are rejected by name. |
| Connect-time IP pinning | Every resolved IP is re-validated inside the socket connect, so a DNS server can't swap in a private IP between the check and the connection (DNS rebinding). |
| Header sanitization | Host, Cookie, X-Forwarded-*, cloud-metadata headers, and other dangerous headers are silently stripped from your request. |
| Safe redirects | Redirects are followed manually (max 5 hops) and every hop re-runs all of the checks above. |
| Credential stripping | Only Accept*, Content-Type/Language/Encoding, and User-Agent survive a redirect that crosses origins; custom API-key headers are dropped. |
| Timeout attacks | The whole request chain (including redirects) is capped at 10 seconds. |
Two things that always need configuration:
- Response size limits apply only to
guardedFetchJson/guardedFetchText(10 MB default). BareguardedFetchreturns theResponseunread, so if you callresponse.json()yourself there's no size cap — prefer the wrappers for untrusted endpoints. - A host allowlist — any publicly-resolving host is allowed unless you
pass
allowedHosts. Entries are explicit matches only; use a leading*.wildcard to allow one subdomain level (*.example.com), or**.for any subdomain depth (**.example.com).
Recipes
User-configured webhook / log drain
Strict timeout, opaque errors, capped redirects — for URLs where the attacker can both choose the destination and read your error messages:
import { guardedFetch, isGuardedFetchError } from 'guarded-fetch';
try {
await guardedFetch(webhook.url, {
method: 'POST',
headers: webhook.headers, // sanitized automatically
body: JSON.stringify(event),
timeoutMs: 4_000,
maxRedirects: 3,
opaqueErrors: true,
});
} catch (err) {
if (isGuardedFetchError(err)) {
// err.code is intact for internal logging;
// err.message is safe to show the user.
logger.info({ code: err.code, hostname: err.hostname }, 'delivery failed');
}
throw err;
}Locked-down fetch to a known vendor
const data = await guardedFetchJson(vendorUrl, {
httpsOnly: true,
allowedHosts: ['api.vendor.com'],
maxResponseBytes: 1 * 1024 * 1024,
throwOnHttpError: true,
});Blocking Node2Shell
Node2Shell (CVE-2026-111497) is a remote code execution bug in the brotli decoder that Node bundles. Your outbound requests can be exploited by a malicious server even if you don't explicitly pick brotli, since the response could be brotli compressed and Node will accept it.
While we recommend patching to a safe Node version, on an unpatched Node
you can mitigate this attack by blocking br encoding:
await guardedFetch(url, { blockedEncodings: ['br'] });
// The SDK transport takes the same option:
const fetch = createGuardedFetch({ blockedEncodings: ['br'] });The request sends Accept-Encoding: gzip, deflate. A server that still
responds with Content-Encoding: br is aborted before any byte reaches the
decoder, and the call throws response_encoding_not_allowed. With
createGuardedFetch, the call rejects with TypeError: fetch failed and that
GuardedFetchError on .cause.
Note that using custom fetcher will not work with this
Size-bounded reads on an existing Response
import { guardedFetch, readBodyAsJson } from 'guarded-fetch';
const response = await guardedFetch(url);
const data = await readBodyAsJson(response, {
maxResponseBytes: 512 * 1024,
});Options reference
All options are optional, and standard RequestInit fields (method,
headers, body, ...) pass through as usual; redirect and signal are
honored but enforced by the guard (see their rows below). maxResponseBytes and
throwOnHttpError exist only on the guardedFetchJson / guardedFetchText
wrappers.
| Option | Type | Default | What it does |
|---|---|---|---|
timeoutMs | number | 10_000 | Deadline in ms for the entire chain: connection, redirects, and (in the JSON/text wrappers) the body read. Bare guardedFetch stops the clock once headers arrive. |
followRedirects | boolean | true | Follow redirects, re-validating every hop. When false, the 3xx response is returned as-is. Ignored when redirect is set. |
redirect | 'follow' | 'manual' | 'error' | 'follow' | Standard fetch redirect policy. 'follow' re-validates every hop, 'manual' returns the 3xx response, 'error' throws redirect_not_allowed. Overrides followRedirects. |
maxRedirects | number | 5 | Redirect hop cap when following redirects. Exceeding it throws too_many_redirects. |
httpsOnly | boolean | false | Restrict to https: URLs (http: and https: are otherwise both allowed). |
allowedHosts | string[] | (none) | Hostname allowlist — explicit exact match only (api.example.com matches only api.example.com). Leading wildcards: *.example.com allows one subdomain level (api.example.com); **.example.com allows any depth (a.b.example.com too). Neither matches the base domain itself. Not public-suffix-aware — scope wildcards to domains you own (**.com would allow every .com host). Omit to allow any public host. Empty array = deny all. |
skipSsrfCheckForAllowedHosts | boolean | false | Skip the DNS/SSRF check for hosts that matched allowedHosts. Only safe for hosts you fully control. |
sanitizeHeaders | boolean | true | Strip dangerous request headers before sending. Set false to send headers verbatim (dangerous with user input). |
allowedCookie | string | (none) | Explicitly send a Cookie header (which sanitization otherwise strips). Dropped on cross-origin redirects, like Authorization. |
blockedEncodings | string[] | [] | Response content codings to reject, e.g. ['br'] on Node without the fix for CVE-2026-111497. Accept-Encoding omits them (unless you set it) and a response that uses one throws response_encoding_not_allowed before any byte is decoded. |
opaqueErrors | boolean | false | Replace every error message with one generic string so failures are indistinguishable to attackers. Error code is preserved. |
onUrlBlocked | function | (none) | Per-call block-event handler; overrides the module-level setUrlBlockedHandler for this call. |
signal | AbortSignal | (none) | External abort signal, combined with the internal timeout. Unlike timeoutMs, it keeps cancelling the returned body after the headers arrive. An abort rejects with the signal's reason before the request is sent, and with network_error once it is in flight. |
fetch | fetch impl | undici.fetch | Injectable fetch for tests. Must be undici-compatible. |
dispatcher | Dispatcher | null | shared safe dispatcher | undici dispatcher for the request. null disables connect-time IP pinning — dangerous; only for when a trusted proxy already controls the connection target. |
maxResponseBytes | number | 10 * 1024 * 1024 | (wrappers only) Max body size; exceeding it throws response_too_large. |
throwOnHttpError | boolean | false | (wrappers only) Throw on non-2xx responses instead of returning the body. |
Always on, not configurable:
- Credential stripping on cross-origin redirects — only
Accept,Accept-Encoding,Accept-Language,Content-Type,Content-Language,Content-Encoding, andUser-Agentare kept; every other caller header, including custom provider API-key headers, is dropped. - RFC 7231 redirect normalization — 301/302/303 responses convert the
method to
GETand drop the body (307/308 preserve both).
What's prevented
Every outbound attempt — the original URL and every redirect hop — is protected against:
| Attack | How it's stopped |
|---|---|
SSRF to private / loopback / link-local IPs, incl. AWS/GCP metadata (169.254.169.254) | Hostname is DNS-resolved and every record classified before connecting. |
SSRF via non-HTTP schemes (file:, ftp:, gopher:, ...) | Protocol allowlist: http:/https: only. |
| DNS rebinding (TTL=0 flip between check and connect) | IP re-validated inside the socket connect via a pinned undici lookup. |
| Multi-record DNS races (one public + one private record) | Rejected if any resolved A/AAAA record is unsafe. |
Private IPv4 hidden in IPv6 literals (::ffff:, 6to4 2002::/16, NAT64 64:ff9b::/96) | Embedded IPv4 is decoded and classified with the same rules as native IPv4. |
Translation to private IPv4 via 64:ff9b:1::/48, ::/96, or ::ffff:0:0:0/96 | Whole range rejected — it addresses a local translator, never a real host. |
| Redirect to an internal host after an initial safe response | Manual redirect following; every hop re-runs all checks. |
| Infinite / abusive redirect chains | Hop cap (maxRedirects, default 5). |
Cloud-credential theft via metadata headers (Metadata-Flavor, X-aws-ec2-metadata-token) | Header stripped. |
Origin-IP spoofing (X-Forwarded-*, Forwarded, Via, X-Real-IP) | Header stripped. |
Virtual-host routing to internal backends via a supplied Host header | Header stripped. |
Session leakage via Cookie to a user-controlled destination | Header stripped (opt back in with allowedCookie). |
| Auth-token leak on cross-origin redirect | Only non-credential headers kept when the redirect changes origin. |
| Memory exhaustion from huge responses; slow-loris bodies | maxResponseBytes + whole-chain timeoutMs (use the JSON/text wrappers). |
| Internal-network mapping via distinguishable error messages | opaqueErrors: true normalizes every failure message. |
| Brotli decoder RCE on unpatched Node (CVE-2026-111497) | blockedEncodings: ['br'] aborts br responses before decoding. |
Blocked network destinations
Requests are refused when the hostname resolves to (or literally is) any of:
- Loopback (
127.0.0.0/8,::1) and unspecified (0.0.0.0,::) - Private ranges (
10/8,172.16/12,192.168/16, IPv6 ULAfc00::/7) - Link-local — where cloud metadata lives (
169.254.0.0/16,fe80::/10) - CGNAT (
100.64.0.0/10) — note this covers Tailscale-style addresses - Documentation ranges (
2001:db8::/32,3fff::/20) - IPv6 forms that embed any of the above (IPv4-mapped, 6to4, NAT64
64:ff9b::/96), plus these ranges in full: the NAT64 local-use prefix64:ff9b:1::/48, IPv4-compatible::/96, and IPv4-translated::ffff:0:0:0/96 localhost,*.localhost,*.local, and*.internal(trailing dots ignored) — blocked by name, regardless of what DNS says
Headers that are stripped
BLOCKED_REQUEST_HEADERS is exported if you need the canonical list. It
covers:
- Hop-by-hop / transport:
Connection,Keep-Alive,TE,Trailer,Transfer-Encoding,Upgrade - Host routing:
Host - Proxy / origin spoofing:
Forwarded,Proxy-Authorization,Via,X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Proto,X-Real-IP - Cloud metadata:
Metadata,Metadata-Flavor,X-Aws-Ec2-Metadata-Token,X-Metadata-Token - Session:
Cookie(seeallowedCookie),Set-Cookie
DNS rebinding protection
guardedFetch defends against DNS rebinding with a two-layer approach:
- Preflight:
assertUrlIsSafeToFetchDNS-resolves the hostname and rejects if any A/AAAA record is private/loopback/link-local. - Connect-time pin: the default undici dispatcher installs a custom
connect.lookup(createGuardedLookup) that re-validates the IP inside the same resolution call the socket connects with. A separate check-then-fetch sequence leaves a window where an attacker's DNS server returns a public IP for the check and169.254.169.254for the connection; this closes it. The lookup always asks for every A/AAAA record — even when the connector wants only one — and rejects the host if any record is unsafe, the answer is empty, or an address does not match its reported family.
Layer 2 is on by default via the shared dispatcher; injected fetch
implementations must be undici-compatible for it to stay active. Pass
dispatcher: null to opt out (dangerous — only behind a trusted proxy that
controls the connection target).
DNS resolution
Both layers resolve hostnames with c-ares (dns.Resolver), not dns.lookup.
dns.lookup runs getaddrinfo on libuv's thread pool, which cannot cancel it
and lets DNS use at most half the pool. A URL whose nameserver never answers
would hold a slot for the OS resolver's full timeout, and two of them are
enough to queue every other dns.lookup in the process behind them. c-ares
holds no thread while it waits, and each resolution gives up after 5 seconds.
Only DNS is consulted: /etc/hosts, nsswitch, and resolv.conf search
domains are not. Nameservers come from the system configuration, or from
dns.setServers() if you call it.
Using createGuardedLookup on its own
A dns.lookup function only runs when the host actually needs resolving.
Node's socket APIs skip DNS entirely for IP literals, so no lookup
implementation is called for net.connect({ host: '127.0.0.1' }) — this one
included. On its own, createGuardedLookup is a DNS-rebinding guard, not a
complete SSRF guard.
guardedFetch is unaffected: it runs assertUrlIsSafeToFetch first, which
rejects unsafe IP literals before any socket work. getSharedGuardedDispatcher
and createGuardedDispatcher are also complete on their own — they pair the
lookup with a pre-connect IP-literal check.
If you are wiring sockets by hand, validate the target first:
import net from 'node:net';
import { assertUrlIsSafeToFetch, createGuardedLookup } from 'guarded-fetch';
await assertUrlIsSafeToFetch(target); // rejects IP literals and unsafe DNS
net.connect({ host, port, lookup: createGuardedLookup() });API surface
| Export | Purpose |
|---|---|
guardedFetch(url, opts?) | SSRF-safe fetch — returns a Response. |
guardedFetchJson<T>(url, opts?) | Fetch + bounded JSON parse. |
guardedFetchText(url, opts?) | Fetch + bounded UTF-8 read. |
createGuardedFetch(opts?) | Fetch-compatible transport for SDKs: connect-time SSRF checks only, no redirects, caller must .close(). See above. |
assertUrlIsSafeToFetch(url, opts?) | Pre-flight validator — no HTTP request, but does resolve DNS. |
sanitizeRequestHeaders(headers) | Strip SSRF/proxy/cookie headers → Headers. |
readBodyAsJson(res, opts?) | Size-bounded JSON reader for an existing Response. |
readBodyAsText(res, opts?) | Size-bounded text reader for an existing Response. |
GuardedFetchError, GuardedFetchErrorCode | Structured error type for every failure mode. |
isGuardedFetchError(v) | Type guard resilient to duplicate module copies. |
isPermanentGuardedFetchError(v) | True when the failure cannot succeed on retry. |
BLOCKED_REQUEST_HEADERS | The canonical header blocklist. |
createGuardedLookup(opts?) | dns.lookup-compatible function that validates and pins resolved IPs inline. Rebinding guard only — see the note below. |
isSafeIpAddress(ip) | Returns true for public IPv4/IPv6 addresses. |
getSharedGuardedDispatcher() | Lazy process-wide undici Agent used by default. Connection-pooled. |
createGuardedDispatcher(opts?) | Fresh undici Agent with IP pinning. Caller must .close(). |
setUrlBlockedHandler(handler) | Register a process-wide block-event handler. |
URL_BLOCKED_LOG_MESSAGE | Suggested constant log message for block events. |
Error codes
Every failure throws GuardedFetchError with a stable string code
(err.code, also available as the GuardedFetchErrorCode enum):
| Code | Meaning | Permanent? |
|---|---|---|
invalid_url | URL string failed to parse. | Yes |
protocol_not_allowed | Scheme outside the allowlist (http:/https:, or https: only with httpsOnly). | Yes |
host_not_allowed | Host not in allowedHosts. | Yes |
hostname_unsafe | Resolved to a private/loopback/link-local address — or DNS failed (fail-closed). | Usually |
timeout | Exceeded timeoutMs. | No |
too_many_redirects | Redirect chain exceeded maxRedirects. | Yes |
redirect_not_allowed | The caller set redirect: 'error' and the server returned a redirect. | Yes |
redirect_invalid | Redirect Location missing or malformed. | Yes |
redirect_to_unsafe_host | A redirect target failed the SSRF/protocol/host checks. | Yes |
response_too_large | Body exceeded maxResponseBytes. | Yes |
response_encoding_not_allowed | Response used a Content-Encoding listed in blockedEncodings. | Yes |
network_error | DNS/TLS/connect failure, in-flight signal abort, or non-2xx throwOnHttpError. | No |
Use isPermanentGuardedFetchError(err) rather than hardcoding the
retryability column.
Observability
Blocked URLs dispatch a { reason, domain, subReason? } event to an
optional handler — nothing is collected until you register one, and handler
errors never reach your request path:
import { setUrlBlockedHandler, URL_BLOCKED_LOG_MESSAGE } from 'guarded-fetch';
// Once at startup — works with any logger or metrics stack:
setUrlBlockedHandler(({ reason, domain, subReason }) => {
logger.info({ reason, domain, subReason }, URL_BLOCKED_LOG_MESSAGE);
});For per-request context, pass onUrlBlocked on the individual call — but
connect-time (DNS-rebinding) blocks only reach the module-level handler, so
register both for full coverage.
FAQ
Why use this instead of plain fetch + an IP check?
A hand-rolled "resolve the hostname, check the IP, then fetch" sequence has a race condition (DNS rebinding) and misses a long tail of bypasses: redirects to internal hosts, IPv6 literals that embed private IPv4 addresses, multi-record DNS responses where only one record is private, and header smuggling. See DNS rebinding protection for how the race is closed.
Why is localhost blocked? I need it for local development.
Blocking loopback is the point — in production, http://127.0.0.1:8080
from user input is an attack. For dev and tests, inject a fetch
implementation instead of weakening the production configuration:
await guardedFetch(url, { fetch: mockFetch }); // testsWhy did my request go through but the destination returned 401/404?
Header sanitization is silent: blocked headers (see
the full list) are dropped, not rejected, so
a destination that relied on one fails on its side. For cookies there's a
narrow opt-in: allowedCookie.
Why does allowedHosts: [] block everything?
An empty array means "allow these zero hosts" — deny-all — not "no allowlist configured". If you build the list dynamically, guard against it ending up empty; to disable allowlisting, omit the option entirely.
The URL is valid, the host is public — why hostname_unsafe?
DNS is fail-closed: resolver errors and empty results block, and one
private A/AAAA record rejects the whole host, even alongside public ones
(split-horizon DNS). Use isPermanentGuardedFetchError(err) to decide
whether to retry.
Can I validate a URL without making a request?
Yes — use assertUrlIsSafeToFetch at write time, e.g. when a user saves a
webhook URL:
import { assertUrlIsSafeToFetch } from 'guarded-fetch';
await assertUrlIsSafeToFetch(body.webhookUrl, { httpsOnly: true });
// Throws GuardedFetchError if unsafe; safe to store otherwise.Note the DNS answer can change between write time and request time — the request-time checks still run on every fetch.
What about error messages leaking information to attackers?
If the URL supplier can read your error messages (e.g. a webhook delivery
log), distinguishable failures let them map your internal network.
opaqueErrors: true collapses every failure into one generic message while
preserving err.code for your internal logs.
What are the limitations?
- Injected
fetchimplementations must be undici-compatible, or connect-time IP pinning silently doesn't apply to them. node:http-levellocalAddress/familyhints set outside this package can reintroduce private-network reachability. Don't combine them with user-controlled URLs.- DNS is fail-closed — resolver errors block as
hostname_unsaferather than surfacing a retryable network error. - Hostnames resolve through DNS only:
/etc/hostsentries, nsswitch sources, and resolv.confsearchdomains are ignored. See DNS resolution. - One unsafe DNS record blocks the whole host, even alongside public records (split-horizon DNS, stray internal records).
- CGNAT,
*.local, and*.internalare always unsafe, which can reject legitimate Tailscale-style, mDNS, or private-zone endpoints. - Header stripping is silent — see the FAQ entry.
- Credential stripping on cross-origin redirects is not configurable —
every header outside a small non-credential allowlist is dropped, so
auth-preserving redirect patterns (e.g. an API 302 to a presigned URL that
also expects the auth header) and custom headers such as
X-Request-Idwill not reach the new origin. Useredirect: 'manual'to follow such hops yourself. allowedHosts: []denies every host — see the FAQ entry.- Redirect chains through link shorteners can exceed the default 5-hop
cap; raise
maxRedirectsif you expect them.
License
Apache-2.0