UniAuth makes outbound HTTP requests in several places: webhook delivery, backchannel logout, OIDC discovery, and SCIM provisioning. In every case, the destination URL is configured by an admin or an OAuth client — which means it could be anything, including a URL that resolves to an internal IP address. This is the classic Server-Side Request Forgery (SSRF) problem. This post explains why the naive solution is insufficient, how DNS rebinding defeats it, and the architecture we built to close the gap.
The SSRF Problem
SSRF occurs when an application can be tricked into making HTTP requests to internal services. In UniAuth's case, an admin configures a webhook URL like https://my-webhook.example.com/hook. If an attacker can make that URL resolve to 169.254.169.254 (the cloud metadata endpoint), UniAuth would fetch instance credentials on the attacker's behalf.
The first line of defense is URL validation at configuration time. UniAuth's isSafeUrl() function in lib/crypto.ts parses the URL and rejects:
- Non-HTTP(S) schemes
- IP literals in private ranges (10/8, 172.16/12, 192.168/16, 169.254/16, 127/8)
- Hostnames like
localhost,.local,.internal - IPv6 loopback (
::1) and link-local (fe80::/10) - IPv4-mapped IPv6 addresses (
::ffff:10.0.0.1) - CGNAT range (100.64/10), multicast, and reserved ranges
// From lib/crypto.ts — synchronous URL safety check
export function isSafeUrl(urlStr: string): boolean {
try {
const url = new URL(urlStr)
if (url.protocol !== "https:" && url.protocol !== "http:") return false
const hostname = url.hostname
if (hostname === "localhost" || hostname === "127.0.0.1") return false
// ... additional checks for private IPs, reserved ranges ...
return true
} catch {
return false
}
}
This catches the obvious cases: an admin typing http://127.0.0.1:8080/admin or http://169.254.169.254/latest/meta-data/. But it has a fundamental limitation.
Why Hostname Checking Is Not Enough: DNS Rebinding
DNS rebinding exploits the time gap between when a hostname is validated and when it is connected to. The attack works like this:
- Attacker registers
evil.example.comwith a DNS server they control. - Admin configures webhook URL
https://evil.example.com/hook. - UniAuth calls
isSafeUrl("https://evil.example.com/hook"). The function does not resolve DNS — it just checks the URL structure. The URL passes. - Later, UniAuth fires the webhook and calls
fetch("https://evil.example.com/hook"). - At this point, the attacker's DNS server returns
169.254.169.254. - The HTTP client connects to the cloud metadata endpoint and sends the webhook payload (which may include cookies or tokens).
Even if we resolve DNS during validation, the attacker can serve a public IP during validation and a private IP during the actual fetch. DNS records have TTLs, and attackers set them to 0 or 1 second. Between validation and fetch (even milliseconds later), the DNS record has changed. This is the time-of-check-to-time-of-use (TOCTOU) vulnerability inherent in DNS rebinding.
The safeFetch Architecture
The solution is to resolve DNS once, validate the resolved IP, and then force the HTTP connection to use that specific IP — preventing any further DNS resolution. This is what lib/safe-fetch.ts implements:
// From lib/safe-fetch.ts
export async function safeFetch(
url: string,
init?: UndiciRequestInit,
): Promise<Response> {
const check = await isSafeUrlAsync(url)
if (!check.safe) {
throw new SafeFetchError(
`URL blocked by SSRF guard: ${redactUrl(url)}`,
"BLOCKED_URL",
)
}
const dispatcher = new Agent({
connect: {
lookup: (_hostname, _options, cb) => {
cb(null, check.ip!, check.family!)
},
},
})
try {
const res = await undiciFetch(url, { ...init, dispatcher })
return res as unknown as Response
} finally {
dispatcher.close().catch(() => {})
}
}
The flow is:
isSafeUrlAsync(url)resolves the hostname viadns.lookup(host, { all: true }), checks every returned IP against the private range blocklist, and returns the first safe IP along with its address family (4 or 6).- Pinned Agent — We create a one-shot
undici.Agentwith a customconnect.lookupfunction that always returns the pre-validated IP. This means undici never callsdns.lookupitself — the DNS resolution we already performed is the only one that happens. - TLS and Host header — Because we only override the
connect.lookup, the HTTP request still uses the original hostname for theHostheader and TLS SNI. This means certificate validation works correctly (the server's TLS certificate must match the hostname, not the IP), and virtual-host routing works as expected. - Cleanup — The agent is closed in a
finallyblock to prevent connection pool leaks. EachsafeFetchcall gets its own agent, so we do not accumulate stale connections.
The Async Resolution Layer
The isSafeUrlAsync() function in lib/crypto.ts is the DNS-resolving counterpart to the synchronous isSafeUrl(). It runs the synchronous checks first (rejecting obviously bad URLs without paying DNS latency), then resolves the hostname:
export async function isSafeUrlAsync(
urlStr: string,
): Promise<{ safe: boolean; ip?: string; family?: 4 | 6 }> {
if (!isSafeUrl(urlStr)) return { safe: false }
try {
const url = new URL(urlStr)
const host = url.hostname
if (net.isIP(host)) {
if (isPrivateIp(host)) return { safe: false }
return { safe: true, ip: host, family: net.isIPv6(host) ? 6 : 4 }
}
const addrs = await dns.lookup(host, { all: true, verbatim: true })
if (addrs.length === 0) return { safe: false }
for (const a of addrs) {
if (isPrivateIp(a.address)) return { safe: false }
}
const first = addrs[0]
return { safe: true, ip: first.address, family: first.family }
} catch {
return { safe: false }
}
}
A critical detail: we pass { all: true } to dns.lookup(). This returns every A and AAAA record for the hostname. If the attacker serves both a public IP and a private IP (multi-homed DNS), we reject the URL because any resolved address in a private range means the hostname is unsafe. This prevents an attacker from racing the resolver by serving a mix of addresses.
IPv4-Mapped IPv6 Edge Cases
A particularly tricky SSRF bypass involves IPv4-mapped IPv6 addresses. The address ::ffff:10.0.0.1 is an IPv6 address that represents the IPv4 address 10.0.0.1. If the private-IP check only handles IPv4 and "normal" IPv6, this bypass lets an attacker reach private IPv4 hosts through IPv6 notation.
Our isPrivateIp() function explicitly handles this:
if (net.isIPv6(ip)) {
const n = ip.toLowerCase().replace(/^\[|\]$/g, "")
if (n === "::" || n === "::1") return true
// IPv4-mapped IPv6: ::ffff:a.b.c.d
const v4map = n.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/)
if (v4map) return isPrivateIp(v4map[1]) // Recurse with the IPv4 part
if (n.startsWith("fc") || n.startsWith("fd")) return true // unique local
if (/^fe[89ab]/i.test(n)) return true // link-local
if (n.startsWith("ff")) return true // multicast
return false
}
When the function encounters an IPv4-mapped IPv6 address, it extracts the embedded IPv4 address and recursively checks it against the IPv4 private ranges. This closes the bypass completely.
What safeFetch Protects
Every outbound HTTP request in UniAuth that targets a user- or admin-configured URL goes through safeFetch():
- Webhook delivery (
lib/webhooks.ts) — webhook URLs are admin-configured, and payloads may contain user data. - Backchannel logout (
lib/backchannel-logout.ts) — logout tokens sent to each OAuth client'sbackchannel_logout_uri. - OIDC discovery — when UniAuth acts as a relying party and fetches a remote provider's
.well-known/openid-configuration. - SCIM provisioning — outbound requests to enterprise identity systems during SCIM sync operations.
Internal fetch calls (e.g., server components calling UniAuth's own API routes) do not use safeFetch() because the destination is hardcoded, not user-influenced.
Error Handling and Observability
When safeFetch() blocks a request, it throws a SafeFetchError with a code property: either BLOCKED_URL (the resolved IP was private) or RESOLUTION_FAILED (DNS lookup failed). The error message includes a redacted URL (scheme + hostname + path, no query string or credentials) for debugging without leaking sensitive URL parameters.
Webhook delivery logs the error as a delivery failure, increments the retry counter, and will retry with exponential backoff. If the URL consistently resolves to a private IP, all retries will fail, and the webhook delivery will be marked as permanently failed after 3 attempts. The admin can see this in the webhook delivery logs and fix the URL.
Why undici Instead of Node's Built-In fetch
We use undici's fetch() instead of the global fetch() (which is also undici under the hood in Node.js) because undici's Agent API gives us control over the connect.lookup function. The global fetch() does not expose this hook — there is no way to pin the DNS resolution for a single request without controlling the dispatcher. Using undici directly gives us the socket-level control needed to defeat DNS rebinding.
The trade-off is a type mismatch: undici's Response and the global Response are structurally identical but TypeScript considers them distinct types. We cast through unknown at the return boundary, which is safe because the runtime types are compatible.