diff --git a/CHANGELOG.md b/CHANGELOG.md index 650d30a43..316d67ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr. - Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr. - Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec. - +- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL. - Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. - Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth. - Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth. diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts new file mode 100644 index 000000000..b058e5e34 --- /dev/null +++ b/src/infra/net/ssrf.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { isPrivateIpAddress } from "./ssrf.js"; + +describe("ssrf ip classification", () => { + it("treats IPv4-mapped and IPv4-compatible IPv6 loopback as private", () => { + expect(isPrivateIpAddress("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIpAddress("0:0:0:0:0:ffff:7f00:1")).toBe(true); + expect(isPrivateIpAddress("0000:0000:0000:0000:0000:ffff:7f00:0001")).toBe(true); + expect(isPrivateIpAddress("::127.0.0.1")).toBe(true); + expect(isPrivateIpAddress("0:0:0:0:0:0:7f00:1")).toBe(true); + expect(isPrivateIpAddress("[0:0:0:0:0:ffff:7f00:1]")).toBe(true); + }); + + it("treats IPv4-mapped metadata/link-local as private", () => { + expect(isPrivateIpAddress("::ffff:169.254.169.254")).toBe(true); + expect(isPrivateIpAddress("0:0:0:0:0:ffff:a9fe:a9fe")).toBe(true); + }); + + it("treats common IPv6 private/internal ranges as private", () => { + expect(isPrivateIpAddress("::")).toBe(true); + expect(isPrivateIpAddress("::1")).toBe(true); + expect(isPrivateIpAddress("fe80::1%lo0")).toBe(true); + expect(isPrivateIpAddress("fd00::1")).toBe(true); + expect(isPrivateIpAddress("fec0::1")).toBe(true); + }); + + it("does not classify public IPs as private", () => { + expect(isPrivateIpAddress("93.184.216.34")).toBe(false); + expect(isPrivateIpAddress("2606:4700:4700::1111")).toBe(false); + expect(isPrivateIpAddress("2001:db8::1")).toBe(false); + }); +}); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 3db709e11..e8c524db9 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -23,7 +23,6 @@ export type SsrFPolicy = { hostnameAllowlist?: string[]; }; -const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"]; const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]); function normalizeHostname(hostname: string): string { @@ -84,35 +83,85 @@ function parseIpv4(address: string): number[] | null { return numbers; } -function parseIpv4FromMappedIpv6(mapped: string): number[] | null { - if (mapped.includes(".")) { - return parseIpv4(mapped); +function stripIpv6ZoneId(address: string): string { + const index = address.indexOf("%"); + return index >= 0 ? address.slice(0, index) : address; +} + +function parseIpv6Hextets(address: string): number[] | null { + let input = stripIpv6ZoneId(address.trim().toLowerCase()); + if (!input) { + return null; } - const parts = mapped.split(":").filter(Boolean); - if (parts.length === 1) { - const value = Number.parseInt(parts[0], 16); - if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) { + + // Handle IPv4-embedded IPv6 like ::ffff:127.0.0.1 by converting the tail to 2 hextets. + if (input.includes(".")) { + const lastColon = input.lastIndexOf(":"); + if (lastColon < 0) { return null; } - return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; + const ipv4 = parseIpv4(input.slice(lastColon + 1)); + if (!ipv4) { + return null; + } + const high = (ipv4[0] << 8) + ipv4[1]; + const low = (ipv4[2] << 8) + ipv4[3]; + input = `${input.slice(0, lastColon)}:${high.toString(16)}:${low.toString(16)}`; } - if (parts.length !== 2) { + + const doubleColonParts = input.split("::"); + if (doubleColonParts.length > 2) { return null; } - const high = Number.parseInt(parts[0], 16); - const low = Number.parseInt(parts[1], 16); - if ( - Number.isNaN(high) || - Number.isNaN(low) || - high < 0 || - low < 0 || - high > 0xffff || - low > 0xffff - ) { + + const headParts = + doubleColonParts[0]?.length > 0 ? doubleColonParts[0].split(":").filter(Boolean) : []; + const tailParts = + doubleColonParts.length === 2 && doubleColonParts[1]?.length > 0 + ? doubleColonParts[1].split(":").filter(Boolean) + : []; + + const missingParts = 8 - headParts.length - tailParts.length; + if (missingParts < 0) { return null; } - const value = (high << 16) + low; - return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; + + const fullParts = + doubleColonParts.length === 1 + ? input.split(":") + : [...headParts, ...Array.from({ length: missingParts }, () => "0"), ...tailParts]; + + if (fullParts.length !== 8) { + return null; + } + + const hextets: number[] = []; + for (const part of fullParts) { + if (!part) { + return null; + } + const value = Number.parseInt(part, 16); + if (Number.isNaN(value) || value < 0 || value > 0xffff) { + return null; + } + hextets.push(value); + } + return hextets; +} + +function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null { + // IPv4-mapped: ::ffff:a.b.c.d (and full-form variants) + // IPv4-compatible: ::a.b.c.d (deprecated, but still needs private-network blocking) + const zeroPrefix = hextets[0] === 0 && hextets[1] === 0 && hextets[2] === 0 && hextets[3] === 0; + if (!zeroPrefix || hextets[4] !== 0) { + return null; + } + if (hextets[5] !== 0xffff && hextets[5] !== 0) { + return null; + } + const high = hextets[6]; + const low = hextets[7]; + return [(high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff]; } function isPrivateIpv4(parts: number[]): boolean { @@ -150,19 +199,54 @@ export function isPrivateIpAddress(address: string): boolean { return false; } - if (normalized.startsWith("::ffff:")) { - const mapped = normalized.slice("::ffff:".length); - const ipv4 = parseIpv4FromMappedIpv6(mapped); - if (ipv4) { - return isPrivateIpv4(ipv4); - } - } - if (normalized.includes(":")) { - if (normalized === "::" || normalized === "::1") { + const hextets = parseIpv6Hextets(normalized); + if (!hextets) { + return false; + } + + const isUnspecified = + hextets[0] === 0 && + hextets[1] === 0 && + hextets[2] === 0 && + hextets[3] === 0 && + hextets[4] === 0 && + hextets[5] === 0 && + hextets[6] === 0 && + hextets[7] === 0; + const isLoopback = + hextets[0] === 0 && + hextets[1] === 0 && + hextets[2] === 0 && + hextets[3] === 0 && + hextets[4] === 0 && + hextets[5] === 0 && + hextets[6] === 0 && + hextets[7] === 1; + if (isUnspecified || isLoopback) { return true; } - return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix)); + + const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets); + if (embeddedIpv4) { + return isPrivateIpv4(embeddedIpv4); + } + + // IPv6 private/internal ranges + // - link-local: fe80::/10 + // - site-local (deprecated, but internal): fec0::/10 + // - unique local: fc00::/7 + const first = hextets[0]; + if ((first & 0xffc0) === 0xfe80) { + return true; + } + if ((first & 0xffc0) === 0xfec0) { + return true; + } + if ((first & 0xfe00) === 0xfc00) { + return true; + } + return false; } const ipv4 = parseIpv4(normalized);