Skip to content

Repository files navigation

@ingram-tech/safe-fetch

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

What it checks

  1. The parsed URL. 0177.0.0.1, 2130706433, 127.1, 0x7f.0.0.1 and ①②⑦.0.0.1 all canonicalise to 127.0.0.1 in new URL(), so the check runs on the parser's output, where a string match would miss them.
  2. The scheme. Only https:. Bun's fetch honours file: URLs, so an unrestricted scheme is a local file read.
  3. The resolved address, which is then the address connected to. A hostname that resolves to 169.254.169.254 passes any name-based check, so the name is resolved. Validating a name and then handing the name to fetch re-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.
  4. 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 the Authorization header 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.

Validating at write time

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 EgressBlocked

A hostname is only judged once safeFetch resolves it, so passing assertPublicUrl does not mean safeFetch will accept the URL.

Options

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.

Runtimes

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 undici

Deno and Workers are not supported: neither exposes a way to pin the connection, so the resolved address could not be the one connected to.

License

MIT

About

fetch for URLs you did not choose: SSRF-safe, DNS-pinned, redirect-revalidated. Bun and Node.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages