diff --git a/CHANGELOG.md b/CHANGELOG.md index e62fbff7a18..4e9bf3250dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. - Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift. - Security/Archive: block zip symlink escapes during archive extraction. - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. - Security/Gateway: block node-role connections when device identity metadata is missing. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 2a1cfeef73f..de0140d76a2 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -33,6 +33,17 @@ describe("fetchWithSsrFGuard hardening", () => { } }); + it("blocks special-use IPv4 literal URLs before fetch", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://198.18.0.1:8080/internal", + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index c2fbbbacd6c..5d8fe8f6620 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,7 +3,20 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ + "198.18.0.1", + "198.19.255.254", + "198.51.100.42", + "203.0.113.10", + "192.0.0.8", + "192.0.2.1", + "192.88.99.1", + "224.0.0.1", + "239.255.255.255", + "240.0.0.1", + "255.255.255.255", "::ffff:127.0.0.1", + "::ffff:198.18.0.1", + "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", "::127.0.0.1", @@ -19,6 +32,7 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", + "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -30,6 +44,13 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", + "198.17.255.255", + "198.20.0.1", + "198.51.99.1", + "198.51.101.1", + "203.0.112.1", + "203.0.114.1", + "223.255.255.255", "2606:4700:4700::1111", "2001:db8::1", "64:ff9b::8.8.8.8", @@ -98,6 +119,11 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); + it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); + }); + it("blocks legacy IPv4 literal representations", () => { expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); expect(isBlockedHostnameOrIp("8.8.2056")).toBe(true); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 2cd7790883f..2301ac8a1d6 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -279,32 +279,59 @@ function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null { return null; } -function isPrivateIpv4(parts: number[]): boolean { - const [octet1, octet2] = parts; - if (octet1 === 0) { - return true; +type Ipv4Cidr = { + base: readonly [number, number, number, number]; + prefixLength: number; +}; + +function ipv4ToUint(parts: readonly number[]): number { + const [a, b, c, d] = parts; + return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0; +} + +function ipv4RangeFromCidr(cidr: Ipv4Cidr): readonly [start: number, end: number] { + const base = ipv4ToUint(cidr.base); + const hostBits = 32 - cidr.prefixLength; + const mask = cidr.prefixLength === 0 ? 0 : (0xffffffff << hostBits) >>> 0; + const start = (base & mask) >>> 0; + const end = (start | (~mask >>> 0)) >>> 0; + return [start, end]; +} + +const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [ + { base: [0, 0, 0, 0], prefixLength: 8 }, + { base: [10, 0, 0, 0], prefixLength: 8 }, + { base: [100, 64, 0, 0], prefixLength: 10 }, + { base: [127, 0, 0, 0], prefixLength: 8 }, + { base: [169, 254, 0, 0], prefixLength: 16 }, + { base: [172, 16, 0, 0], prefixLength: 12 }, + { base: [192, 0, 0, 0], prefixLength: 24 }, + { base: [192, 0, 2, 0], prefixLength: 24 }, + { base: [192, 88, 99, 0], prefixLength: 24 }, + { base: [192, 168, 0, 0], prefixLength: 16 }, + { base: [198, 18, 0, 0], prefixLength: 15 }, + { base: [198, 51, 100, 0], prefixLength: 24 }, + { base: [203, 0, 113, 0], prefixLength: 24 }, + { base: [224, 0, 0, 0], prefixLength: 4 }, + { base: [240, 0, 0, 0], prefixLength: 4 }, +]; + +const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr); + +function isBlockedIpv4SpecialUse(parts: number[]): boolean { + if (parts.length !== 4) { + return false; } - if (octet1 === 10) { - return true; - } - if (octet1 === 127) { - return true; - } - if (octet1 === 169 && octet2 === 254) { - return true; - } - if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) { - return true; - } - if (octet1 === 192 && octet2 === 168) { - return true; - } - if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) { - return true; + const value = ipv4ToUint(parts); + for (const [start, end] of BLOCKED_IPV4_SPECIAL_USE_RANGES) { + if (value >= start && value <= end) { + return true; + } } return false; } +// Returns true for private/internal and special-use non-global addresses. export function isPrivateIpAddress(address: string): boolean { let normalized = address.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { @@ -345,7 +372,7 @@ export function isPrivateIpAddress(address: string): boolean { const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets); if (embeddedIpv4) { - return isPrivateIpv4(embeddedIpv4); + return isBlockedIpv4SpecialUse(embeddedIpv4); } // IPv6 private/internal ranges @@ -367,7 +394,7 @@ export function isPrivateIpAddress(address: string): boolean { const ipv4 = parseIpv4(normalized); if (ipv4) { - return isPrivateIpv4(ipv4); + return isBlockedIpv4SpecialUse(ipv4); } // Reject non-canonical IPv4 literal forms (octal/hex/short/packed) by default. if (isUnsupportedLegacyIpv4Literal(normalized)) { @@ -485,7 +512,7 @@ export async function resolvePinnedHostnameWithPolicy( } if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) { - throw new SsrFBlockedError("Blocked hostname or private/internal IP address"); + throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -497,7 +524,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!allowPrivateNetwork && !isExplicitAllowed) { for (const entry of results) { if (isPrivateIpAddress(entry.address)) { - throw new SsrFBlockedError("Blocked: resolves to private/internal IP address"); + throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"); } } }