ruvnet/ruflo · critical · Error
Resolved IP for ${hostname} is internal (${address})
Error message
Resolved IP for ${hostname} is internal (${address}) What it means
Thrown by assertSafeIp(), the SSRF defence wired into undici's custom DNS lookup so the resolved IP is checked at connect time (closing the TOCTOU window that pure hostname validation leaves open). isUnsafeIp() blocks 0.0.0.0/8, 100.64.0.0/10 (CGNAT), 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, IPv6 loopback/link-local, IPv4-mapped IPv6 (::ffff:...), and any unparseable address. It is a hard security guard, not a configurable allowlist.
Source
Thrown at ruflo/src/ruvocal/src/lib/server/urlSafety.ts:75
// If the hostname is a raw IP literal, validate it
const cleanHostname = hostname.replace(/^\[|]$/g, "");
if (isIP(cleanHostname)) {
return !isUnsafeIp(cleanHostname);
}
return true;
} catch {
return false;
}
}
/**
* Assert that a resolved IP address is safe (not internal/private).
* Throws if the IP is internal. Used in undici's custom DNS lookup
* to validate IPs at connection time (prevents TOCTOU DNS rebinding).
*/
export function assertSafeIp(address: string, hostname: string): void {
if (isUnsafeIp(address)) {
throw new Error(`Resolved IP for ${hostname} is internal (${address})`);
}
}
View on GitHub (pinned to 6b01dc5a68)
Solutions
- Use a public hostname for the target so DNS returns a routable IP.
- If a private target is genuinely required (dev/local MCP bridge), route that call through a code path that does NOT use the assertSafeIp lookup, or run with the dev posture that allows localhost/host.docker.internal.
- For a hostname you control, point its DNS at a public IP instead of an RFC1918 address.
- Confirm you are not the victim of DNS rebinding: resolve the hostname twice and compare, or pin the expected IP.
- Note the blocklist omits 10.0.0.0/8 and 224.0.0.0/4 — do not rely on this gap; validate your own trust boundary explicitly.
Example fix
// before
await fetch(userSuppliedUrl); // throws if DNS resolves to 127.0.0.1
// after
if (!isValidUrl(userSuppliedUrl)) throw new Error("disallowed URL");
try {
await fetch(userSuppliedUrl); // assertSafeIp runs inside the DNS lookup
} catch (e) {
if (String(e?.message ?? "").includes("is internal")) throw new Error("blocked: internal target");
throw e;
} Defensive patterns
Strategy: try-catch
Validate before calling
import { isValidUrl } from "$lib/server/urlSafety";
function isFetchableUrl(urlString: string): boolean {
return isValidUrl(urlString); // synchronous protocol+hostname check (pre-DNS)
}
// call before fetch:
if (!isFetchableUrl(target)) throw new Error(`refusing to fetch ${target}`); Type guard
function isPublicHostname(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|]$/g, "");
// note: this is the *string* check only; the real SSRF guard is assertSafeIp at connect time
return h !== "localhost" && h !== "127.0.0.1" && h !== "::1" && h.includes(".");
} Try / catch
import { assertSafeIp } from "$lib/server/urlSafety";
try {
await fetch(target); // assertSafeIp runs inside undici's DNS lookup
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (msg.includes("is internal")) {
// security policy: do not retry, do not fall back; log and reject
throw new Error(`blocked SSRF attempt: ${msg}`);
}
throw e; // genuine network error, caller may retry
} Prevention
- Never disable the custom undici DNS lookup to work around this; widen a documented allowlist instead.
- Resolve and log the target IP once before fetch in dev to catch internal targets early.
- For dev-only internal targets, route them through a code path that does not use assertSafeIp rather than weakening the guard.
- Watch for IPv4-mapped IPv6 (::ffff:127.0.0.1) in user-supplied URLs — the guard catches these, but pre-filtering is cheaper.
When it happens
Trigger: Any outbound fetch whose hostname DNS-resolves to a private/loopback/link-local address: a URL pointing at localhost/127.0.0.1 in a non-dev posture, an internal service name that resolves onto the 172.16/12 or 192.168/16 range, a DNS-rebinding attack where a public-looking hostname flips to 127.0.0.1 between the isValidUrl check and connect, or an IPv6 ::1/::ffff:127.0.0.1 literal.
Common situations: Self-hosted deployments fetching an internal MCP/LLM endpoint by internal IP; Docker setups where a service name resolves to a bridge-network address; a test that hardcodes http://127.0.0.1 against the production-style fetch path; legitimate link-local AWS metadata-style addresses (169.254.x) being fetched.
Related errors
- SSRF guard: private/loopback host rejected — ${host}
- SSRF guard: private/loopback host rejected — ${host}
- PRIVATE_ADDRESS
- SSRF guard: invalid URL — ${rawUrl}
- SSRF guard: only HTTPS URLs are permitted, got ${parsed.prot
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/ac5b2de3fa3b5f45.
Report an issue: GitHub.