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-fetch
import {
  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:

BehaviorguardedFetchcreateGuardedFetch()
URL, IP-literal, and DNS checksYes (preflight + connect-time pin)Yes (connect-time pin only)
Redirectsredirect option; 'follow' (default) re-validates every hopNot followed — 'manual' or 'error'
Header sanitizationYesNo
TimeouttimeoutMs (10 s default)None — pass a signal
Response size limitWith the JSON/text wrappersNone
opaqueErrors / onUrlBlockedYesNo (module-level setUrlBlockedHandler only)
blockedEncodingsYesYes (trusted origins too)
Error on a connect-time blockGuardedFetchErrorTypeError: fetch failed, GuardedFetchError on .cause
Error on a refused redirectGuardedFetchError redirect_not_allowedTypeError: 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:

ProtectionDefault behavior
Protocol allowlistOnly http: and https: URLs are accepted.
SSRF DNS checkThe 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 pinningEvery 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 sanitizationHost, Cookie, X-Forwarded-*, cloud-metadata headers, and other dangerous headers are silently stripped from your request.
Safe redirectsRedirects are followed manually (max 5 hops) and every hop re-runs all of the checks above.
Credential strippingOnly Accept*, Content-Type/Language/Encoding, and User-Agent survive a redirect that crosses origins; custom API-key headers are dropped.
Timeout attacksThe 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). Bare guardedFetch returns the Response unread, so if you call response.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.

OptionTypeDefaultWhat it does
timeoutMsnumber10_000Deadline 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.
followRedirectsbooleantrueFollow 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.
maxRedirectsnumber5Redirect hop cap when following redirects. Exceeding it throws too_many_redirects.
httpsOnlybooleanfalseRestrict to https: URLs (http: and https: are otherwise both allowed).
allowedHostsstring[](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.
skipSsrfCheckForAllowedHostsbooleanfalseSkip the DNS/SSRF check for hosts that matched allowedHosts. Only safe for hosts you fully control.
sanitizeHeadersbooleantrueStrip dangerous request headers before sending. Set false to send headers verbatim (dangerous with user input).
allowedCookiestring(none)Explicitly send a Cookie header (which sanitization otherwise strips). Dropped on cross-origin redirects, like Authorization.
blockedEncodingsstring[][]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.
opaqueErrorsbooleanfalseReplace every error message with one generic string so failures are indistinguishable to attackers. Error code is preserved.
onUrlBlockedfunction(none)Per-call block-event handler; overrides the module-level setUrlBlockedHandler for this call.
signalAbortSignal(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.
fetchfetch implundici.fetchInjectable fetch for tests. Must be undici-compatible.
dispatcherDispatcher | nullshared safe dispatcherundici dispatcher for the request. null disables connect-time IP pinning — dangerous; only for when a trusted proxy already controls the connection target.
maxResponseBytesnumber10 * 1024 * 1024(wrappers only) Max body size; exceeding it throws response_too_large.
throwOnHttpErrorbooleanfalse(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, and User-Agent are 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 GET and drop the body (307/308 preserve both).

What's prevented

Every outbound attempt — the original URL and every redirect hop — is protected against:

AttackHow 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/96Whole range rejected — it addresses a local translator, never a real host.
Redirect to an internal host after an initial safe responseManual redirect following; every hop re-runs all checks.
Infinite / abusive redirect chainsHop 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 headerHeader stripped.
Session leakage via Cookie to a user-controlled destinationHeader stripped (opt back in with allowedCookie).
Auth-token leak on cross-origin redirectOnly non-credential headers kept when the redirect changes origin.
Memory exhaustion from huge responses; slow-loris bodiesmaxResponseBytes + whole-chain timeoutMs (use the JSON/text wrappers).
Internal-network mapping via distinguishable error messagesopaqueErrors: 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 ULA fc00::/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 prefix 64: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 (see allowedCookie), Set-Cookie

DNS rebinding protection

guardedFetch defends against DNS rebinding with a two-layer approach:

  1. Preflight: assertUrlIsSafeToFetch DNS-resolves the hostname and rejects if any A/AAAA record is private/loopback/link-local.
  2. 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 and 169.254.169.254 for 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

ExportPurpose
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, GuardedFetchErrorCodeStructured 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_HEADERSThe 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_MESSAGESuggested 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):

CodeMeaningPermanent?
invalid_urlURL string failed to parse.Yes
protocol_not_allowedScheme outside the allowlist (http:/https:, or https: only with httpsOnly).Yes
host_not_allowedHost not in allowedHosts.Yes
hostname_unsafeResolved to a private/loopback/link-local address — or DNS failed (fail-closed).Usually
timeoutExceeded timeoutMs.No
too_many_redirectsRedirect chain exceeded maxRedirects.Yes
redirect_not_allowedThe caller set redirect: 'error' and the server returned a redirect.Yes
redirect_invalidRedirect Location missing or malformed.Yes
redirect_to_unsafe_hostA redirect target failed the SSRF/protocol/host checks.Yes
response_too_largeBody exceeded maxResponseBytes.Yes
response_encoding_not_allowedResponse used a Content-Encoding listed in blockedEncodings.Yes
network_errorDNS/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 }); // tests

Why 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 fetch implementations must be undici-compatible, or connect-time IP pinning silently doesn't apply to them.
  • node:http-level localAddress/family hints 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_unsafe rather than surfacing a retryable network error.
  • Hostnames resolve through DNS only: /etc/hosts entries, nsswitch sources, and resolv.conf search domains 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 *.internal are 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-Id will not reach the new origin. Use redirect: '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 maxRedirects if you expect them.

License

Apache-2.0