server-fetch
v1.0.10
Published
SSRF-safe fetch() for server-side use — validates IPs, enforces scheme/port, eliminates DNS rebinding via undici connect.lookup
Maintainers
Readme
server-fetch
Hardened fetch() for Node.js — safe against SSRF, DNS rebinding, oversized responses, and hung connections.
Validates URLs against private/reserved IP ranges, allows only HTTP(S) on ports 80/443, caps response bodies at 10 MB (configurable via maxResponseSize), times out at 10 s (configurable via timeout), and shares one DNS lookup between validation and TCP connect via undici's connect.lookup — no TOCTOU gap.
Install
pnpm add server-fetch undiciundici is a required peer dependency.
Usage
import { serverFetch } from 'server-fetch'
// Drop-in replacement for fetch() — rejects private IPs at connect time
const res = await serverFetch('https://example.com/api', {
method: 'POST',
body: JSON.stringify({ url: userInput }),
timeout: 5000, // optional, defaults to 10_000 (10 s)
maxResponseSize: 5 * 1024 * 1024, // optional, defaults to 10 MB; pass Infinity to disable
})Options
| Option | Type | Default | Description |
| ----------------- | ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| timeout | number (ms) | 10_000 (10 s) | Aborts the request if it doesn't complete in time. |
| maxResponseSize | number (bytes) | 10_485_760 (10 MB) | Caps the response body size. Rejects synchronously if Content-Length exceeds the limit; otherwise undici throws ResponseExceededMaxSizeError during body consumption (.text(), .json(), …). Pass Infinity to disable. Must be a positive integer or Infinity — anything else throws SsrfError('INVALID_OPTION'). |
All other RequestInit options except signal are forwarded to undici.fetch unchanged.
Validate without fetching
For registration-time checks (e.g., saving a webhook URL):
import { validateUrl } from 'server-fetch'
// Resolves DNS and checks all returned addresses
const { hostname, resolvedIps, parsed } = await validateUrl(url)Warning: Using
validateUrl()then passing the URL to a separatefetch()reintroduces the DNS rebinding TOCTOU window. UseserverFetch()for actual requests.
Custom agent
import { createSsrfSafeAgent } from 'server-fetch'
// SSRF-safe lookup is always applied; your options are merged in
const agent = createSsrfSafeAgent({ connections: 10 })What it blocks
Protocols: Only http and https are allowed.
Ports: Only 80 and 443.
IP ranges:
| IPv4 | Purpose |
| ---------------- | --------------------------- |
| 0.0.0.0/8 | "This network" |
| 10.0.0.0/8 | Private (RFC 1918) |
| 100.64.0.0/10 | Carrier-grade NAT |
| 127.0.0.0/8 | Loopback |
| 169.254.0.0/16 | Link-local / cloud metadata |
| 172.16.0.0/12 | Private (RFC 1918) |
| 192.0.0.0/24 | IETF protocol assignments |
| 192.168.0.0/16 | Private (RFC 1918) |
| 198.18.0.0/15 | Benchmarking |
| 240.0.0.0/4 | Reserved |
| IPv6 | Purpose |
| ----------------- | ----------------------- |
| ::1/128 | Loopback |
| ::/128 | Unspecified |
| fc00::/7 | Unique local |
| fe80::/10 | Link-local |
| ::ffff:0:0:0/96 | SIIT IPv4-translated |
| 64:ff9b::/96 | NAT64 well-known prefix |
| 64:ff9b:1::/48 | NAT64 local-use prefix |
Error handling
All rejections throw SsrfError with a typed code:
import { serverFetch, SsrfError } from 'server-fetch'
try {
await serverFetch(url)
} catch (e) {
if (e instanceof SsrfError) {
console.log(e.code) // INVALID_URL | BLOCKED_PROTOCOL | BLOCKED_PORT | BLOCKED_IP | DNS_FAILED | RESPONSE_TOO_LARGE | INVALID_OPTION
console.log(e.url) // the offending URL
}
}How it works
validateUrl()parses the URL, checks protocol/port, resolves DNS with{ all: true }, and rejects if any address is private.serverFetch()callsvalidateUrl()for an early error, then fetches through an undiciAgentwhoseconnect.lookuphook validates the resolved IP inside the connection handshake — the same address that passes validation is the one used for TCP connect. No second DNS query, no rebinding window.
License
MIT
