mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:51:24 +00:00
fix(ssrf): centralize host/ip block checks
This commit is contained in:
@@ -99,7 +99,7 @@ describe("buildGatewayCronService", () => {
|
|||||||
|
|
||||||
loadConfigMock.mockReturnValue(cfg);
|
loadConfigMock.mockReturnValue(cfg);
|
||||||
fetchWithSsrFGuardMock.mockRejectedValue(
|
fetchWithSsrFGuardMock.mockRejectedValue(
|
||||||
new SsrFBlockedError("Blocked: private/internal IP address"),
|
new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const state = buildGatewayCronService({
|
const state = buildGatewayCronService({
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ describe("ssrf pinning", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects private DNS results", async () => {
|
it.each([
|
||||||
const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]) as unknown as LookupFn;
|
{ name: "RFC1918 private address", address: "10.0.0.8" },
|
||||||
|
{ name: "RFC2544 benchmarking range", address: "198.18.0.1" },
|
||||||
|
])("rejects blocked DNS results: $name", async ({ address }) => {
|
||||||
|
const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn;
|
||||||
await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
|
await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,8 @@ const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [
|
|||||||
{ base: [240, 0, 0, 0], prefixLength: 4 },
|
{ base: [240, 0, 0, 0], prefixLength: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Keep this table as the single source of IPv4 non-global policy.
|
||||||
|
// Both plain IPv4 literals and IPv6-embedded IPv4 forms flow through it.
|
||||||
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
|
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
|
||||||
|
|
||||||
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
|
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
|
||||||
@@ -430,6 +432,24 @@ export function isBlockedHostnameOrIp(hostname: string): boolean {
|
|||||||
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized);
|
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address";
|
||||||
|
const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address";
|
||||||
|
|
||||||
|
function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void {
|
||||||
|
if (isBlockedHostnameOrIp(hostnameOrIp)) {
|
||||||
|
throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void {
|
||||||
|
for (const entry of results) {
|
||||||
|
// Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift.
|
||||||
|
if (isBlockedHostnameOrIp(entry.address)) {
|
||||||
|
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createPinnedLookup(params: {
|
export function createPinnedLookup(params: {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
@@ -506,13 +526,15 @@ export async function resolvePinnedHostnameWithPolicy(
|
|||||||
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
|
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
|
||||||
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
|
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
|
||||||
const isExplicitAllowed = allowedHostnames.has(normalized);
|
const isExplicitAllowed = allowedHostnames.has(normalized);
|
||||||
|
const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed;
|
||||||
|
|
||||||
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
|
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
|
||||||
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
|
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) {
|
if (!skipPrivateNetworkChecks) {
|
||||||
throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address");
|
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
|
||||||
|
assertAllowedHostOrIpOrThrow(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupFn = params.lookupFn ?? dnsLookup;
|
const lookupFn = params.lookupFn ?? dnsLookup;
|
||||||
@@ -521,12 +543,9 @@ export async function resolvePinnedHostnameWithPolicy(
|
|||||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowPrivateNetwork && !isExplicitAllowed) {
|
if (!skipPrivateNetworkChecks) {
|
||||||
for (const entry of results) {
|
// Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets.
|
||||||
if (isPrivateIpAddress(entry.address)) {
|
assertAllowedResolvedAddressesOrThrow(results);
|
||||||
throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addresses = Array.from(new Set(results.map((entry) => entry.address)));
|
const addresses = Array.from(new Set(results.map((entry) => entry.address)));
|
||||||
|
|||||||
Reference in New Issue
Block a user