SSRF-safe fetch for Node
Fetch user-supplied URLs from a server route while blocking private addresses, pinning DNS, limiting redirects, time and size.
React / Next.jsAPIsssrfsecuritynodefetch
Code
import dns from 'node:dns/promises';
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
const blocked = new net.BlockList();
for (const [ip, bits] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4],
] as const) blocked.addSubnet(ip, bits, 'ipv4');
for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['::ffff:0:0', 96]] as const) {
blocked.addSubnet(ip, bits, 'ipv6'); // ::ffff:0:0/96 covers IPv4-mapped addresses
}
async function resolvePublic(host: string) {
const bare = host.replace(/^\[|\]$/g, '');
const records = net.isIP(bare) ? [{ address: bare, family: net.isIP(bare) }] : await dns.lookup(bare, { all: true });
for (const r of records) {
if (blocked.check(r.address, r.family === 6 ? 'ipv6' : 'ipv4')) throw new Error('Blocked address');
}
if (!records.length) throw new Error('Host did not resolve');
return records[0]!;
}
type Opts = { maxBytes?: number; timeoutMs?: number; maxRedirects?: number };
export async function safeFetch(input: string, { maxBytes = 1_000_000, timeoutMs = 8000, maxRedirects = 3 }: Opts = {}) {
let url = new URL(input);
for (let hop = 0; hop <= maxRedirects; hop++) {
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Only http and https are allowed');
const target = await resolvePublic(url.hostname);
const lib = url.protocol === 'https:' ? https : http;
const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = lib.request(url, {
method: 'GET',
headers: { 'user-agent': 'acme-audit/1.0', accept: 'text/html,*/*;q=0.5' },
// Pin the vetted IP so a second DNS answer cannot swap in an internal host.
lookup: (_h, _o, cb) => cb(null, target.address, target.family),
}, resolve);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Timed out')));
req.on('error', reject);
req.end();
});
const location = res.headers.location;
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && location) {
res.resume();
url = new URL(location, url); // re-validated on the next loop iteration
continue;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of res) {
size += (chunk as Buffer).length;
if (size > maxBytes) { res.destroy(); throw new Error('Response too large'); }
chunks.push(chunk as Buffer);
}
return { status: res.statusCode ?? 0, url: url.toString(), body: Buffer.concat(chunks).toString('utf8') };
}
throw new Error('Too many redirects');
}
How it works
What it solves
Any feature that fetches a URL on behalf of a visitor, such as an audit tool, link preview or webhook test, can be abused for server-side request forgery. An attacker submits an address that resolves to your cloud metadata service or an internal admin panel and reads the response. This helper makes the fetch safe to expose.
How it works
safeFetch accepts only http and https, then resolves the hostname with dns.lookup requesting all addresses. Every address is checked against a BlockList of loopback, private, link-local, carrier-grade NAT, multicast and reserved ranges, for both IPv4 and IPv6, including IPv4-mapped IPv6. If any address is blocked, the request is refused.
The validated address is then pinned: the request is made with a custom lookup function that returns that exact IP, so the connection cannot be redirected by a second, malicious DNS answer between check and use. This closes the DNS rebinding gap. The original hostname is still used for TLS and the Host header.
Redirects are followed manually, up to a small limit, and each hop runs through the same validation. A hard timeout destroys the socket, and the body is streamed with a byte counter that aborts once the cap is passed. Only the capped text is returned.
Adapting it
- Add a port allowlist such as 80 and 443.
- Return headers and status for audit features.
- Run it only in server code; never import it in client bundles.
Pitfalls
- Egress firewall rules remain the strongest defence; use both.
- Do not forward cookies or credentials to arbitrary hosts.
How to use it
- Save as
lib/safeFetch.tsand import it only from route handlers or server actions (Node runtime, not Edge). const { status, body } = await safeFetch(url, { maxBytes: 500_000, timeoutMs: 8000 }).- Catch the thrown error and return a generic 400 to the client.
- Keep your own validation of which hosts users may request.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.