SSRF protection for Node.js and Cloudflare Workers.
The key differentiator: validateUrl returns the resolved IP addresses so you can pin them directly to the socket — eliminating the TOCTOU/DNS-rebind window that exists between a validation step and the actual fetch. safeFetch does this automatically.
Unlike request-filtering-agent, ssrf-guard exposes the resolved addresses to the caller, letting you reuse them across retries or pass them to your own HTTP client.
pnpm add ssrf-guard
Requires Node.js ≥ 24. The ssrf-guard entry point (isPrivateIp, validateResolvedAddresses, etc.) is pure and also runs in Cloudflare Workers. The ssrf-guard/node entry point requires Node.js and uses node:dns, node:net, and undici.
import { isPrivateIp } from "ssrf-guard";
isPrivateIp("127.0.0.1"); // true
isPrivateIp("10.0.0.1"); // true
isPrivateIp("::ffff:10.0.0.1"); // true (IPv4-mapped IPv6)
isPrivateIp("0x7f000001"); // true (hex form of 127.0.0.1)
isPrivateIp("8.8.8.8"); // falseimport { validateUrl } from "ssrf-guard/node";
const addresses = await validateUrl("https://example.com/", {
blockedHostnames: {
exact: ["localhost", "metadata.google.internal"],
suffixes: [".local", ".internal"],
},
});
// addresses: [{ address: '93.184.216.34', family: 4 }]
// Now use those addresses to build a pinned dispatcher — DNS won't be
// queried again so rebinding between check and fetch is impossible.import { safeFetch } from "ssrf-guard/node";
const response = await safeFetch("https://example.com/image.png", {
blockedHostnames: {
exact: ["metadata.google.internal"],
suffixes: [".internal"],
},
headers: { "user-agent": "my-crawler/1.0" },
});safeFetch resolves DNS once, validates the result, pins the addresses to the socket via an undici Agent, and follows redirects — re-validating each hop.
Returns true if ip is a private, loopback, link-local, or unspecified address. Handles all RFC-legal IPv4 forms (dotted decimal, octal components, hex components, integer), IPv6, IPv4-mapped IPv6 (::ffff:), and ULA/link-local IPv6 ranges.
Lowercases, strips trailing dots, and unwraps brackets from IPv6 hostnames as extracted from a URL object.
Returns true if hostname matches an exact entry or a suffix in policy.
Filters out null-route addresses (0.0.0.0, ::), throws UnsafeResolvedAddressError for private IPs, and throws with code: DNS_NULL_ROUTE_CODE when no usable addresses remain.
Thrown by validateResolvedAddresses. Properties: rawUrl: string, address: string.
String constant 'DNS_NULL_ROUTE' — the code property on the error thrown when DNS resolves only to null-route addresses.
interface BlockedHostnamePolicy {
exact: readonly string[];
suffixes: readonly string[];
}interface ResolvedSafeAddress {
address: string;
family: 4 | 6;
}Validates a URL and returns the resolved addresses:
- Parses the URL — throws
UnsafeUrlErrorfor invalid URLs. - Rejects non-
http:/https:schemes. - Checks against
blockedHostnamespolicy. - Rejects literal private IP addresses without DNS lookup.
- Resolves DNS and validates all returned addresses.
interface ValidateUrlOptions {
blockedHostnames?: BlockedHostnamePolicy;
}Fetches a URL safely:
- Validates and pins DNS addresses before each hop.
- Follows redirects up to
maxRedirects(default: 10), re-validating each. - Passes remaining
RequestInitoptions through toundici.
interface SafeFetchOptions extends Omit<RequestInit, "signal"> {
blockedHostnames?: BlockedHostnamePolicy;
maxRedirects?: number;
signal?: AbortSignal;
}Creates an undici Agent whose lookup callback is hardwired to the provided addresses, preventing any further DNS resolution.
Thrown by validateUrl and safeFetch. Properties: rawUrl: string, reason: string.
MIT