@spfn/core/security
Helpers for making outbound requests safely. The headline export is safeFetch,
an SSRF-hardened drop-in for fetch.
Why
Any time the backend fetches a URL that a user can influence — a webhook target, an
image URL, an OAuth callback, a $ref in stored data — an attacker can point it at an
internal address (http://169.254.169.254/… for cloud credentials, http://127.0.0.1:…
for internal services). That is SSRF (Server-Side Request Forgery).
A string allowlist or a "block these hostnames" check does not stop it: the attacker
controls DNS, so evil.com can resolve to 169.254.169.254 after your check passes
(DNS rebinding). The only robust defense is to resolve the host, validate the
resolved IP, and pin the connection to that validated IP.
safeFetch(input, init?)
Same signature as fetch. It:
- checks the protocol (default
http:/https:) and any host allowlist; - resolves the hostname and rejects if it points at a private/reserved range;
- pins the connection to a validated IP via a custom undici
lookup, closing the rebinding window between check and connect; - follows redirects manually and re-validates every hop — including a hop whose
target is a bare IP literal (which undici would otherwise connect to directly, skipping
the pinning lookup). Capped at
maxRedirects(default 5).
The original method, body, and headers are not replayed across a redirect: the next
hop may be attacker-chosen, so forwarding the payload or auth headers would leak them — the
redirected request is issued as a header-less GET.
Blocked targets throw SsrfBlockedError.
import { safeFetch } from '@spfn/core/security';
const res = await safeFetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
Policy
Private/reserved IPs are blocked by default. Configure the process-wide default once at
boot, or build a scoped fetch with createSafeFetch(policy):
// server.config.ts — sets the default safeFetch() policy
export default defineServerConfig()
.outboundFetch({ allowHosts: ['hooks.slack.com'] })
.build();
// or a one-off, scoped instance (reuse it — it owns a pooled dispatcher)
import { createSafeFetch } from '@spfn/core/security';
const fetchSlack = createSafeFetch({ allowHosts: ['hooks.slack.com'] });
| Field | Type | Default | Notes |
|---|---|---|---|
allowedProtocols |
string[] |
['http:','https:'] |
Permitted URL schemes. |
blockPrivateIps |
boolean |
true |
Block private/reserved ranges (loopback, link-local/metadata, RFC1918, CGNAT, ULA, multicast, NAT64/6to4). |
allowHosts |
string[] |
— | Exact hostname allowlist (case-insensitive), enforced on every redirect hop. Strongest control for a known upstream set. |
maxRedirects |
number |
5 |
Redirects to follow, each re-validated. |
Env: SAFE_FETCH_BLOCK_PRIVATE_IPS sets the boot default for blockPrivateIps
(keep true in production; false only for trusted internal-network calls in dev).
Compatibility note. The Slack webhook sender (
@spfn/notification) now routes throughsafeFetch, so a webhook pointed at a private/internal address (self-hosted Mattermost, an internal Slack-compatible endpoint on10.x/192.168.x, etc.) will now fail to send. Allow it explicitly withoutboundFetch({ allowHosts: ['your.host'] }), or — if the target is a raw private IP —outboundFetch({ blockPrivateIps: false })(which relaxes the check for all outbound calls, so preferallowHosts).
assertSafeUrl(url, policy?)
When the actual request is made by code you don't control (e.g. an app-provided
storage download(url)), you can't pin the connection. Validate the URL first:
import { assertSafeUrl } from '@spfn/core/security';
if (/^https?:\/\//i.test(ref)) await assertSafeUrl(ref); // throws SsrfBlockedError if internal
return storage.download(ref);
This runs the same protocol/allowlist/DNS-resolution checks but cannot prevent
rebinding between the check and that code's own connection — it raises the bar (blocks
the obvious metadata/loopback injection) rather than fully closing SSRF. Prefer
safeFetch whenever you own the request.
Also here
isPrivateOrReservedIp(ip)— the IP classifier, exported for reuse/testing.proxy-signature— HMAC signing/verification for the proxy→backend trust path (see@spfn/core/middlewareproxy-guard).