mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user