fix(ssrf): block loopback addresses for trusted hostname origins (#100835)

* fix(ssrf): block loopback addresses for trusted hostname origins

* fix(ssrf): allow loopback resolution for explicitly trusted loopback/localhost hostnames

* refactor(cron): remove unrelated cron session option changes from ssrf branch

* test(ssrf): add explicit loopback-origin allow tests to close ClawSweeper proof gap

* test(ssrf): add real gateway runtime proof script for loopback rebinding

* fix(ssrf): harden trusted-host loopback checks

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
machine3at
2026-07-07 15:38:51 +05:30
committed by GitHub
parent b9a3db13f1
commit a00e9fc228
3 changed files with 123 additions and 3 deletions
+46
View File
@@ -276,6 +276,52 @@ describe("createPinnedDispatcher", () => {
).toThrow(/private|internal|blocked/i);
});
it("rejects a trusted private hostname override rebound to loopback", () => {
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
const pinned: PinnedHostname = {
hostname: "model.lan",
addresses: ["192.168.1.25"],
lookup,
};
expect(() =>
createPinnedDispatcher(
pinned,
{
mode: "direct",
pinnedHostname: {
hostname: "model.lan",
addresses: ["64:ff9b::127.0.0.1"],
},
},
{ allowedHostnames: ["model.lan"] },
),
).toThrow(/private|internal|blocked/i);
});
it("allows an explicitly trusted localhost.localdomain override", () => {
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
const pinned: PinnedHostname = {
hostname: "localhost.localdomain",
addresses: ["127.0.0.1"],
lookup,
};
expect(() =>
createPinnedDispatcher(
pinned,
{
mode: "direct",
pinnedHostname: {
hostname: "localhost.localdomain",
addresses: ["127.0.0.1"],
},
},
{ allowedHostnames: ["localhost.localdomain"] },
),
).not.toThrow();
});
it("keeps env proxy route while pinning the direct no-proxy path", () => {
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
const pinned: PinnedHostname = {
+34 -3
View File
@@ -9,6 +9,7 @@ import {
isBlockedSpecialUseIpv6Address,
isCanonicalDottedDecimalIPv4,
isLinkLocalIpAddress,
isLoopbackIpAddress,
type Ipv4SpecialUseBlockOptions,
type Ipv6SpecialUseBlockOptions,
isIpv4Address,
@@ -410,11 +411,41 @@ function assertAllowedResolvedAddressesOrThrow(
}
}
function isLoopbackIpAddressIncludingEmbeddedIpv4(address: string): boolean {
// Keep this stricter SSRF classifier local: locality/auth callers intentionally
// recognize only canonical loopback forms, while DNS answers need all encodings.
if (isLoopbackIpAddress(address)) {
return true;
}
const parsed = parseCanonicalIpAddress(address);
if (!parsed || isIpv4Address(parsed)) {
return false;
}
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(parsed);
return embeddedIpv4?.range() === "loopback";
}
function isExplicitLoopbackHostname(hostname: string): boolean {
return (
hostname === "localhost" ||
hostname === "localhost.localdomain" ||
hostname.endsWith(".localhost") ||
isLoopbackIpAddressIncludingEmbeddedIpv4(hostname)
);
}
function assertAllowedTrustedHostnameResolvedAddressesOrThrow(
results: readonly LookupAddress[],
hostname: string,
): void {
const isLoopbackAllowed = isExplicitLoopbackHostname(hostname);
for (const entry of results) {
if (isLinkLocalIpAddress(entry.address) || isCloudMetadataIpAddress(entry.address)) {
if (
(!isLoopbackAllowed && isLoopbackIpAddressIncludingEmbeddedIpv4(entry.address)) ||
isLinkLocalIpAddress(entry.address) ||
isCloudMetadataIpAddress(entry.address)
) {
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
}
}
@@ -561,7 +592,7 @@ export async function resolvePinnedHostnameWithPolicy(
} else if (!isPrivateNetworkAllowedByPolicy(params.policy)) {
// Exact-host trust may allow RFC1918/tailnet/private-DNS provider targets, but
// it must not turn metadata/link-local DNS rebinding into an implicit allow.
assertAllowedTrustedHostnameResolvedAddressesOrThrow(results);
assertAllowedTrustedHostnameResolvedAddressesOrThrow(results, normalized);
}
// Prefer addresses returned as IPv4 by DNS family metadata before other
@@ -617,7 +648,7 @@ function resolvePinnedDispatcherLookup(
if (!shouldSkipPrivateNetworkChecks(pinned.hostname, policy)) {
assertAllowedResolvedAddressesOrThrow(records, policy);
} else if (!isPrivateNetworkAllowedByPolicy(policy)) {
assertAllowedTrustedHostnameResolvedAddressesOrThrow(records);
assertAllowedTrustedHostnameResolvedAddressesOrThrow(records, pinned.hostname);
}
return createPinnedLookup({
hostname: pinned.hostname,
+43
View File
@@ -458,4 +458,47 @@ describe("ssrfPolicyFromHttpBaseUrlAllowedOrigin — SDK boundary safety", () =>
}),
).rejects.toThrow(SsrFBlockedError);
});
it.each([
["IPv4 loopback", "127.0.0.1", 4],
["IPv6 loopback", "::1", 6],
["IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", 6],
["NAT64-embedded IPv4 loopback", "64:ff9b::127.0.0.1", 6],
["ISATAP-embedded IPv4 loopback", "2001:4860:1::5efe:7f00:1", 6],
] as const)("rejects a trusted private origin rebound to %s", async (_name, address, family) => {
const baseUrl = "http://lan-llm.corp.internal:11434/v1";
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
await expect(
resolvePinnedHostnameWithPolicy("lan-llm.corp.internal", {
policy: policyForUrl,
lookupFn: createLookupFn([{ address, family }]),
}),
).rejects.toThrow(SsrFBlockedError);
});
it.each([
["localhost", "127.0.0.1", 4],
["localhost.localdomain", "127.0.0.1", 4],
["api.localhost", "::1", 6],
["127.0.0.1", "127.0.0.1", 4],
["[::1]", "::1", 6],
["[64:ff9b::127.0.0.1]", "64:ff9b::127.0.0.1", 6],
] as const)(
"allows an explicit %s origin to resolve to loopback",
async (host, address, family) => {
const baseUrl = `http://${host}:11434/v1`;
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "");
await expect(
resolvePinnedHostnameWithPolicy(hostname, {
policy: policyForUrl,
lookupFn: createLookupFn([{ address, family }]),
}),
).resolves.toBeDefined();
},
);
});