fetch for URLs you did not choose.
A server that dereferences user-supplied URLs (webhook sinks, MCP servers,
OAuth endpoints, "import from URL") lets the user pick a host and have your
process request it from inside your network. safeFetch refuses the hosts
that must not be reached. Bun and Node 20+.
npm install @ingram-tech/safe-fetchimport { safeFetch, EgressBlocked } from "@ingram-tech/safe-fetch";
const res = await safeFetch(webhookUrl, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(event),
});Same signature and semantics as fetch. When a URL must not be dereferenced it
throws EgressBlocked; network failures throw whatever fetch throws.
- The parsed URL.
0177.0.0.1,2130706433,127.1,0x7f.0.0.1and①②⑦.0.0.1all canonicalise to127.0.0.1innew URL(), so the check runs on the parser's output, where a string match would miss them. - The scheme. Only
https:. Bun'sfetchhonoursfile:URLs, so an unrestricted scheme is a local file read. - The resolved address, which is then the address connected to. A
hostname that resolves to
169.254.169.254passes any name-based check, so the name is resolved. Validating a name and then handing the name tofetchre-resolves it, and a different answer the second time is the DNS-rebinding attack. The name is resolved once, every answer is checked, and the connection is pinned to that address with SNI and certificate verification kept on the original name. - Every redirect hop. An external endpoint can 302 into link-local space
past a check that only saw the configured URL. Redirects are followed by
hand, the whole guard runs on each
Location, and theAuthorizationheader is dropped when a redirect changes origin.
The address check is an allowlist of globally routable addresses. Loopback,
RFC 1918, CGNAT, link-local, multicast, ULA, and the IPv6 forms that embed an
IPv4 address (v4-mapped, NAT64, 6to4) are refused. Names under .internal,
.local, .localhost and .home.arpa are refused without resolving.
assertPublicUrl runs the synchronous part of the guard, scheme and the
address when the host is already a literal, so a form can reject a bad URL
when it is saved instead of storing it and failing later.
import { assertPublicUrl } from "@ingram-tech/safe-fetch";
assertPublicUrl("https://hooks.example.com/in"); // → URL
assertPublicUrl("https://169.254.169.254/"); // throws EgressBlockedA hostname is only judged once safeFetch resolves it, so passing
assertPublicUrl does not mean safeFetch will accept the URL.
import { createSafeFetch } from "@ingram-tech/safe-fetch";
const guard = createSafeFetch({
allowInsecure: process.env.NODE_ENV === "development",
maxRedirects: 3,
});
await guard.fetch(url, init);
guard.assertPublicUrl(url);| Option | Default | |
|---|---|---|
allowInsecure |
false |
Allow http: and loopback targets, so a server under test on localhost is reachable. |
maxRedirects |
3 |
Redirect hops followed before EgressBlocked("too many redirects"). |
lookup |
dns.lookup |
Replace DNS resolution. Tests use it to stay offline. |
fetch |
globalThis.fetch |
The underlying fetch. |
The same options are accepted as a third argument to safeFetch and a second
argument to assertPublicUrl.
On Bun the connection is pinned with tls.serverName, which Bun's fetch
reads for SNI and certificate checks. On Node the pin goes through an
undici Agent whose connector answers with
the judged address, so undici is an optional peer dependency there:
npm install undiciDeno and Workers are not supported: neither exposes a way to pin the connection, so the resolved address could not be the one connected to.
MIT