CVE-2026-32019
Description
OpenClaw versions prior to 2026.2.22 contain incomplete IPv4 special-use range validation in the isPrivateIpv4() function, allowing requests to RFC-reserved ranges to bypass SSRF policy checks. Attackers with network reachability to special-use IPv4 ranges can exploit web_fetch functionality to access blocked addresses such as 198.18.0.0/15 and other non-global ranges.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.22 | 2026.2.22 |
Affected products
1Patches
4f14ebd743cfcrefactor(security): unify local-host and tailnet CIDR checks
7 files changed · +63 −31
src/browser/client-fetch.loopback-auth.test.ts+10 −0 modified@@ -104,4 +104,14 @@ describe("fetchBrowserJson loopback auth", () => { const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBe("Bearer loopback-token"); }); + + it("injects auth for IPv4-mapped IPv6 loopback URLs", async () => { + const fetchMock = stubJsonFetchOk(); + + await fetchBrowserJson<{ ok: boolean }>("http://[::ffff:127.0.0.1]:18888/"); + + const init = fetchMock.mock.calls[0]?.[1]; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer loopback-token"); + }); });
src/browser/client-fetch.ts+2 −6 modified@@ -1,5 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { loadConfig } from "../config/config.js"; +import { isLoopbackHost } from "../gateway/net.js"; import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; import { resolveBrowserControlAuth } from "./control-auth.js"; import { @@ -20,12 +21,7 @@ function isAbsoluteHttp(url: string): boolean { function isLoopbackHttpUrl(url: string): boolean { try { - const host = new URL(url).hostname.trim().toLowerCase(); - // URL hostnames may keep IPv6 brackets (for example "[::1]"); normalize before checks. - const normalizedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - return ( - normalizedHost === "127.0.0.1" || normalizedHost === "localhost" || normalizedHost === "::1" - ); + return isLoopbackHost(new URL(url).hostname); } catch { return false; }
src/gateway/auth.ts+2 −6 modified@@ -12,9 +12,9 @@ import { type RateLimitCheckResult, } from "./auth-rate-limit.js"; import { + isLocalishHost, isLoopbackAddress, isTrustedProxyAddress, - resolveHostName, resolveClientIp, } from "./net.js"; @@ -133,18 +133,14 @@ export function isLocalDirectRequest( return false; } - const host = resolveHostName(req.headers?.host); - const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; - const hostIsTailscaleServe = host.endsWith(".ts.net"); - const hasForwarded = Boolean( req.headers?.["x-forwarded-for"] || req.headers?.["x-real-ip"] || req.headers?.["x-forwarded-host"], ); const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); - return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy); + return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy); } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
src/gateway/net.test.ts+23 −0 modified@@ -1,6 +1,7 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + isLocalishHost, isPrivateOrLoopbackAddress, isSecureWebSocketUrl, isTrustedProxyAddress, @@ -24,6 +25,28 @@ describe("resolveHostName", () => { }); }); +describe("isLocalishHost", () => { + it("accepts loopback and tailscale serve/funnel host headers", () => { + const accepted = [ + "localhost", + "127.0.0.1:18789", + "[::1]:18789", + "[::ffff:127.0.0.1]:18789", + "gateway.tailnet.ts.net", + ]; + for (const host of accepted) { + expect(isLocalishHost(host), host).toBe(true); + } + }); + + it("rejects non-local hosts", () => { + const rejected = ["example.com", "192.168.1.10", "203.0.113.5:18789"]; + for (const host of rejected) { + expect(isLocalishHost(host), host).toBe(false); + } + }); +}); + describe("isTrustedProxyAddress", () => { describe("exact IP matching", () => { it("returns true when IP matches exactly", () => {
src/gateway/net.ts+13 −0 modified@@ -334,6 +334,19 @@ export function isLoopbackHost(host: string): boolean { return isLoopbackAddress(unbracket); } +/** + * Local-facing host check for inbound requests: + * - loopback hosts (localhost/127.x/::1 and mapped forms) + * - Tailscale Serve/Funnel hostnames (*.ts.net) + */ +export function isLocalishHost(hostHeader?: string): boolean { + const host = resolveHostName(hostHeader); + if (!host) { + return false; + } + return isLoopbackHost(host) || host.endsWith(".ts.net"); +} + /** * Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information). *
src/gateway/server/ws-connection/message-handler.ts+7 −6 modified@@ -41,8 +41,12 @@ import { mintCanvasCapabilityToken, } from "../../canvas-capability.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js"; -import { resolveHostName } from "../../net.js"; +import { + isLocalishHost, + isLoopbackAddress, + isTrustedProxyAddress, + resolveClientIp, +} from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { checkBrowserOrigin } from "../../origin-check.js"; import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; @@ -164,10 +168,7 @@ export function attachGatewayWsMessageHandler(params: { const hasProxyHeaders = Boolean(forwardedFor || realIp); const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; - const hostName = resolveHostName(requestHost); - const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1"; - const hostIsTailscaleServe = hostName.endsWith(".ts.net"); - const hostIsLocalish = hostIsLocal || hostIsTailscaleServe; + const hostIsLocalish = isLocalishHost(requestHost); const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback); const reportedClientIp = isLocalClient || hasUntrustedProxyHeaders
src/infra/tailnet.ts+6 −13 modified@@ -1,31 +1,24 @@ import os from "node:os"; +import { isIpInCidr } from "../shared/net/ip.js"; export type TailnetAddresses = { ipv4: string[]; ipv6: string[]; }; -export function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((p) => Number.parseInt(p, 10)); - if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { - return false; - } +const TAILNET_IPV4_CIDR = "100.64.0.0/10"; +const TAILNET_IPV6_CIDR = "fd7a:115c:a1e0::/48"; +export function isTailnetIPv4(address: string): boolean { // Tailscale IPv4 range: 100.64.0.0/10 // https://tailscale.com/kb/1015/100.x-addresses - const [a, b] = octets; - return a === 100 && b >= 64 && b <= 127; + return isIpInCidr(address, TAILNET_IPV4_CIDR); } function isTailnetIPv6(address: string): boolean { // Tailscale IPv6 ULA prefix: fd7a:115c:a1e0::/48 // (stable across tailnets; nodes get per-device suffixes) - const normalized = address.trim().toLowerCase(); - return normalized.startsWith("fd7a:115c:a1e0:"); + return isIpInCidr(address, TAILNET_IPV6_CIDR); } export function listTailnetAddresses(): TailnetAddresses {
333fbb863479refactor(net): consolidate IP checks with ipaddr.js
9 files changed · +417 −479
package.json+1 −0 modified@@ -170,6 +170,7 @@ "file-type": "^21.3.0", "grammy": "^1.40.0", "https-proxy-agent": "^7.0.6", + "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1",
pnpm-lock.yaml+24 −5 modified@@ -119,6 +119,9 @@ importers: https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -4076,6 +4079,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + ipull@3.9.3: resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==} engines: {node: '>=18.0.0'} @@ -6873,7 +6880,7 @@ snapshots: '@larksuiteoapi/node-sdk@1.59.0': dependencies: - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -6889,7 +6896,7 @@ snapshots: dependencies: '@types/node': 24.10.13 optionalDependencies: - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 transitivePeerDependencies: - debug @@ -7078,7 +7085,7 @@ snapshots: '@azure/core-auth': 1.10.1 '@azure/msal-node': 5.0.4 '@microsoft/agents-activity': 1.3.1 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -7980,7 +7987,7 @@ snapshots: '@slack/types': 2.20.0 '@slack/web-api': 7.14.1 '@types/express': 5.0.6 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 express: 5.2.1 path-to-regexp: 8.3.0 raw-body: 3.0.2 @@ -8026,7 +8033,7 @@ snapshots: '@slack/types': 2.20.0 '@types/node': 25.3.0 '@types/retry': 0.12.0 - axios: 1.13.5(debug@4.4.3) + axios: 1.13.5 eventemitter3: 5.0.4 form-data: 2.5.4 is-electron: 2.2.2 @@ -8915,6 +8922,14 @@ snapshots: aws4@1.13.2: {} + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 2.5.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -9484,6 +9499,8 @@ snapshots: flatbuffers@24.12.23: {} + follow-redirects@1.15.11: {} + follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -9808,6 +9825,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} + ipull@3.9.3: dependencies: '@tinyhttp/content-disposition': 2.2.4
src/gateway/net.test.ts+5 −0 modified@@ -81,6 +81,11 @@ describe("isTrustedProxyAddress", () => { expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match }); + + it("supports IPv6 CIDR notation", () => { + expect(isTrustedProxyAddress("2001:db8::1234", ["2001:db8::/32"])).toBe(true); + expect(isTrustedProxyAddress("2001:db9::1234", ["2001:db8::/32"])).toBe(false); + }); }); describe("backward compatibility", () => {
src/gateway/net.ts+16 −125 modified@@ -1,6 +1,13 @@ import net from "node:net"; import os from "node:os"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; +import { + isCanonicalDottedDecimalIPv4, + isIpInCidr, + isLoopbackIpAddress, + isPrivateOrLoopbackIpAddress, + normalizeIpAddress, +} from "../shared/net/ip.js"; /** * Pick the primary non-internal IPv4 address (LAN IP). @@ -49,81 +56,19 @@ export function resolveHostName(hostHeader?: string): string { } export function isLoopbackAddress(ip: string | undefined): boolean { - if (!ip) { - return false; - } - if (ip === "127.0.0.1") { - return true; - } - if (ip.startsWith("127.")) { - return true; - } - if (ip === "::1") { - return true; - } - if (ip.startsWith("::ffff:127.")) { - return true; - } - return false; + return isLoopbackIpAddress(ip); } /** * Returns true if the IP belongs to a private or loopback network range. * Private ranges: RFC1918, link-local, ULA IPv6, and CGNAT (100.64/10), plus loopback. */ export function isPrivateOrLoopbackAddress(ip: string | undefined): boolean { - if (!ip) { - return false; - } - if (isLoopbackAddress(ip)) { - return true; - } - const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase()); - const family = net.isIP(normalized); - if (!family) { - return false; - } - - if (family === 4) { - const octets = normalized.split(".").map((value) => Number.parseInt(value, 10)); - if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) { - return false; - } - const [o1, o2] = octets; - // RFC1918 IPv4 private ranges. - if (o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)) { - return true; - } - // IPv4 link-local and CGNAT (commonly used by Tailnet-like networks). - if ((o1 === 169 && o2 === 254) || (o1 === 100 && o2 >= 64 && o2 <= 127)) { - return true; - } - return false; - } - - // IPv6 unique-local and link-local ranges. - if (normalized.startsWith("fc") || normalized.startsWith("fd")) { - return true; - } - if (/^fe[89ab]/.test(normalized)) { - return true; - } - return false; -} - -function normalizeIPv4MappedAddress(ip: string): string { - if (ip.startsWith("::ffff:")) { - return ip.slice("::ffff:".length); - } - return ip; + return isPrivateOrLoopbackIpAddress(ip); } function normalizeIp(ip: string | undefined): string | undefined { - const trimmed = ip?.trim(); - if (!trimmed) { - return undefined; - } - return normalizeIPv4MappedAddress(trimmed.toLowerCase()); + return normalizeIpAddress(ip); } function stripOptionalPort(ip: string): string { @@ -193,51 +138,6 @@ function resolveForwardedClientIp(params: { return undefined; } -/** - * Check if an IP address matches a CIDR block. - * Supports IPv4 CIDR notation (e.g., "10.42.0.0/24"). - * - * @param ip - The IP address to check (e.g., "10.42.0.59") - * @param cidr - The CIDR block (e.g., "10.42.0.0/24") - * @returns True if the IP is within the CIDR block - */ -function ipMatchesCIDR(ip: string, cidr: string): boolean { - // Handle exact IP match (no CIDR notation) - if (!cidr.includes("/")) { - return ip === cidr; - } - - const [subnet, prefixLenStr] = cidr.split("/"); - const prefixLen = parseInt(prefixLenStr, 10); - - // Validate prefix length - if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) { - return false; - } - - // Convert IPs to 32-bit integers - const ipParts = ip.split(".").map((p) => parseInt(p, 10)); - const subnetParts = subnet.split(".").map((p) => parseInt(p, 10)); - - // Validate IP format - if ( - ipParts.length !== 4 || - subnetParts.length !== 4 || - ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) || - subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) - ) { - return false; - } - - const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; - const subnetInt = - (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3]; - - // Create mask and compare - const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen); - return (ipInt & mask) === (subnetInt & mask); -} - export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean { const normalized = normalizeIp(ip); if (!normalized || !trustedProxies || trustedProxies.length === 0) { @@ -249,12 +149,7 @@ export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: s if (!candidate) { return false; } - // Handle CIDR notation - if (candidate.includes("/")) { - return ipMatchesCIDR(normalized, candidate); - } - // Exact IP match - return normalizeIp(candidate) === normalized; + return isIpInCidr(normalized, candidate); }); } @@ -296,7 +191,10 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { if (!ip) { return false; } - const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase()); + const normalized = normalizeIp(ip); + if (!normalized) { + return false; + } const tailnetIPv4 = pickPrimaryTailnetIPv4(); if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) { return true; @@ -415,14 +313,7 @@ export async function resolveGatewayListenHosts( * @returns True if valid IPv4 format */ export function isValidIPv4(host: string): boolean { - const parts = host.split("."); - if (parts.length !== 4) { - return false; - } - return parts.every((part) => { - const n = parseInt(part, 10); - return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); - }); + return isCanonicalDottedDecimalIPv4(host); } /**
src/infra/net/ssrf.ts+29 −304 modified@@ -1,6 +1,16 @@ import { lookup as dnsLookupCb, type LookupAddress } from "node:dns"; import { lookup as dnsLookup } from "node:dns/promises"; import { Agent, type Dispatcher } from "undici"; +import { + extractEmbeddedIpv4FromIpv6, + isBlockedSpecialUseIpv4Address, + isCanonicalDottedDecimalIPv4, + isIpv4Address, + isLegacyIpv4Literal, + isPrivateOrLoopbackIpAddress, + parseCanonicalIpAddress, + parseLooseIpAddress, +} from "../../shared/net/ip.js"; import { normalizeHostname } from "./hostname.js"; type LookupCallback = ( @@ -68,269 +78,17 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern)); } -function parseStrictIpv4Octet(part: string): number | null { - if (!/^[0-9]+$/.test(part)) { - return null; - } - const value = Number.parseInt(part, 10); - if (Number.isNaN(value) || value < 0 || value > 255) { - return null; - } - // Accept only canonical decimal octets (no leading zeros, no alternate radices). - if (part !== String(value)) { - return null; - } - return value; -} - -function parseIpv4(address: string): number[] | null { - const parts = address.split("."); - if (parts.length !== 4) { - return null; - } - for (const part of parts) { - if (parseStrictIpv4Octet(part) === null) { - return null; - } - } - return parts.map((part) => Number.parseInt(part, 10)); -} - -function classifyIpv4Part(part: string): "decimal" | "hex" | "invalid-hex" | "non-numeric" { - if (/^0x[0-9a-f]+$/i.test(part)) { - return "hex"; - } - if (/^0x/i.test(part)) { - return "invalid-hex"; - } - if (/^[0-9]+$/.test(part)) { - return "decimal"; - } - return "non-numeric"; -} - -function isUnsupportedLegacyIpv4Literal(address: string): boolean { +function looksLikeUnsupportedIpv4Literal(address: string): boolean { const parts = address.split("."); if (parts.length === 0 || parts.length > 4) { return false; } if (parts.some((part) => part.length === 0)) { return true; } - - const partKinds = parts.map(classifyIpv4Part); - if (partKinds.some((kind) => kind === "non-numeric")) { - return false; - } - if (partKinds.some((kind) => kind === "invalid-hex")) { - return true; - } - - if (parts.length !== 4) { - return true; - } - for (const part of parts) { - if (/^0x/i.test(part)) { - return true; - } - const value = Number.parseInt(part, 10); - if (Number.isNaN(value) || value > 255 || part !== String(value)) { - return true; - } - } - return false; -} - -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; - } - - // 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; - } - 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)}`; - } - - const doubleColonParts = input.split("::"); - if (doubleColonParts.length > 2) { - return null; - } - - 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 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 decodeIpv4FromHextets(high: number, low: number): number[] { - return [(high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff]; -} - -type EmbeddedIpv4Rule = { - matches: (hextets: number[]) => boolean; - extract: (hextets: number[]) => [high: number, low: number]; -}; - -const EMBEDDED_IPV4_RULES: EmbeddedIpv4Rule[] = [ - { - // IPv4-mapped: ::ffff:a.b.c.d and IPv4-compatible ::a.b.c.d. - matches: (hextets) => - hextets[0] === 0 && - hextets[1] === 0 && - hextets[2] === 0 && - hextets[3] === 0 && - hextets[4] === 0 && - (hextets[5] === 0xffff || hextets[5] === 0), - extract: (hextets) => [hextets[6], hextets[7]], - }, - { - // NAT64 well-known prefix: 64:ff9b::/96. - matches: (hextets) => - hextets[0] === 0x0064 && - hextets[1] === 0xff9b && - hextets[2] === 0 && - hextets[3] === 0 && - hextets[4] === 0 && - hextets[5] === 0, - extract: (hextets) => [hextets[6], hextets[7]], - }, - { - // NAT64 local-use prefix: 64:ff9b:1::/48. - matches: (hextets) => - hextets[0] === 0x0064 && - hextets[1] === 0xff9b && - hextets[2] === 0x0001 && - hextets[3] === 0 && - hextets[4] === 0 && - hextets[5] === 0, - extract: (hextets) => [hextets[6], hextets[7]], - }, - { - // 6to4 prefix: 2002::/16 where hextets[1..2] carry IPv4. - matches: (hextets) => hextets[0] === 0x2002, - extract: (hextets) => [hextets[1], hextets[2]], - }, - { - // Teredo prefix: 2001:0000::/32 with client IPv4 obfuscated via XOR 0xffff. - matches: (hextets) => hextets[0] === 0x2001 && hextets[1] === 0x0000, - extract: (hextets) => [hextets[6] ^ 0xffff, hextets[7] ^ 0xffff], - }, - { - // ISATAP IID format: 000000ug00000000:5efe:w.x.y.z (RFC 5214 section 6.1). - // Match only the IID marker bits to avoid over-broad :5efe: detection. - matches: (hextets) => (hextets[4] & 0xfcff) === 0 && hextets[5] === 0x5efe, - extract: (hextets) => [hextets[6], hextets[7]], - }, -]; - -function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null { - for (const rule of EMBEDDED_IPV4_RULES) { - if (!rule.matches(hextets)) { - continue; - } - const [high, low] = rule.extract(hextets); - return decodeIpv4FromHextets(high, low); - } - return null; -} - -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 }, -]; - -// 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); - -function isBlockedIpv4SpecialUse(parts: number[]): boolean { - if (parts.length !== 4) { - return false; - } - const value = ipv4ToUint(parts); - for (const [start, end] of BLOCKED_IPV4_SPECIAL_USE_RANGES) { - if (value >= start && value <= end) { - return true; - } - } - return false; + // Tighten only "ipv4-ish" literals (numbers + optional 0x prefix). Hostnames like + // "example.com" must stay in hostname policy handling and not be treated as malformed IPs. + return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part)); } // Returns true for private/internal and special-use non-global addresses. @@ -343,63 +101,30 @@ export function isPrivateIpAddress(address: string): boolean { return false; } - if (normalized.includes(":")) { - const hextets = parseIpv6Hextets(normalized); - if (!hextets) { - // Security-critical parse failures should fail closed. - return true; + const strictIp = parseCanonicalIpAddress(normalized); + if (strictIp) { + if (isIpv4Address(strictIp)) { + return isBlockedSpecialUseIpv4Address(strictIp); } - - 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) { + if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { return true; } - - const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets); + const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); if (embeddedIpv4) { - return isBlockedIpv4SpecialUse(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 isBlockedSpecialUseIpv4Address(embeddedIpv4); } return false; } - const ipv4 = parseIpv4(normalized); - if (ipv4) { - return isBlockedIpv4SpecialUse(ipv4); + // Security-critical parse failures should fail closed for any malformed IPv6 literal. + if (normalized.includes(":") && !parseLooseIpAddress(normalized)) { + return true; + } + + if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) { + return true; } - // Reject non-canonical IPv4 literal forms (octal/hex/short/packed) by default. - if (isUnsupportedLegacyIpv4Literal(normalized)) { + if (looksLikeUnsupportedIpv4Literal(normalized)) { return true; } return false;
src/pairing/setup-code.ts+3 −33 modified@@ -1,5 +1,6 @@ import os from "node:os"; import type { OpenClawConfig } from "../config/types.js"; +import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; const DEFAULT_GATEWAY_PORT = 18789; @@ -113,43 +114,12 @@ function resolveScheme( return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; } -function parseIPv4Octets(address: string): [number, number, number, number] | null { - const parts = address.split("."); - if (parts.length !== 4) { - return null; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { - return null; - } - return [octets[0], octets[1], octets[2], octets[3]]; -} - function isPrivateIPv4(address: string): boolean { - const octets = parseIPv4Octets(address); - if (!octets) { - return false; - } - const [a, b] = octets; - if (a === 10) { - return true; - } - if (a === 172 && b >= 16 && b <= 31) { - return true; - } - if (a === 192 && b === 168) { - return true; - } - return false; + return isRfc1918Ipv4Address(address); } function isTailnetIPv4(address: string): boolean { - const octets = parseIPv4Octets(address); - if (!octets) { - return false; - } - const [a, b] = octets; - return a === 100 && b >= 64 && b <= 127; + return isCarrierGradeNatIpv4Address(address); } function pickIPv4Matching(
src/shared/net/ip.test.ts+52 −0 added@@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + extractEmbeddedIpv4FromIpv6, + isCanonicalDottedDecimalIPv4, + isIpInCidr, + isIpv6Address, + isLegacyIpv4Literal, + isPrivateOrLoopbackIpAddress, + parseCanonicalIpAddress, +} from "./ip.js"; + +describe("shared ip helpers", () => { + it("distinguishes canonical dotted IPv4 from legacy forms", () => { + expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true); + expect(isCanonicalDottedDecimalIPv4("0177.0.0.1")).toBe(false); + expect(isLegacyIpv4Literal("0177.0.0.1")).toBe(true); + expect(isLegacyIpv4Literal("127.1")).toBe(true); + expect(isLegacyIpv4Literal("example.com")).toBe(false); + }); + + it("matches both IPv4 and IPv6 CIDRs", () => { + expect(isIpInCidr("10.42.0.59", "10.42.0.0/24")).toBe(true); + expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false); + expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true); + expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false); + }); + + it("extracts embedded IPv4 for transition prefixes", () => { + const cases = [ + ["::ffff:127.0.0.1", "127.0.0.1"], + ["::127.0.0.1", "127.0.0.1"], + ["64:ff9b::8.8.8.8", "8.8.8.8"], + ["64:ff9b:1::10.0.0.1", "10.0.0.1"], + ["2002:0808:0808::", "8.8.8.8"], + ["2001::f7f7:f7f7", "8.8.8.8"], + ["2001:4860:1::5efe:7f00:1", "127.0.0.1"], + ] as const; + for (const [ipv6Literal, expectedIpv4] of cases) { + const parsed = parseCanonicalIpAddress(ipv6Literal); + expect(parsed?.kind(), ipv6Literal).toBe("ipv6"); + if (!parsed || !isIpv6Address(parsed)) { + continue; + } + expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4); + } + }); + + it("treats deprecated site-local IPv6 as private/internal", () => { + expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); + }); +});
src/shared/net/ip.ts+283 −0 added@@ -0,0 +1,283 @@ +import ipaddr from "ipaddr.js"; + +export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; +type Ipv4Range = ReturnType<ipaddr.IPv4["range"]>; +type Ipv6Range = ReturnType<ipaddr.IPv6["range"]>; + +const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set<Ipv4Range>([ + "unspecified", + "broadcast", + "multicast", + "linkLocal", + "loopback", + "carrierGradeNat", + "private", + "reserved", +]); + +const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<Ipv4Range>([ + "loopback", + "private", + "linkLocal", + "carrierGradeNat", +]); + +const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<Ipv6Range>([ + "unspecified", + "loopback", + "linkLocal", + "uniqueLocal", +]); + +const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ + matches: (parts: number[]) => boolean; + toHextets: (parts: number[]) => [high: number, low: number]; +}> = [ + { + // IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases). + matches: (parts) => + parts[0] === 0 && + parts[1] === 0 && + parts[2] === 0 && + parts[3] === 0 && + parts[4] === 0 && + parts[5] === 0, + toHextets: (parts) => [parts[6], parts[7]], + }, + { + // NAT64 local-use prefix: 64:ff9b:1::/48. + matches: (parts) => + parts[0] === 0x0064 && + parts[1] === 0xff9b && + parts[2] === 0x0001 && + parts[3] === 0 && + parts[4] === 0 && + parts[5] === 0, + toHextets: (parts) => [parts[6], parts[7]], + }, + { + // 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2). + matches: (parts) => parts[0] === 0x2002, + toHextets: (parts) => [parts[1], parts[2]], + }, + { + // Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7). + matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000, + toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff], + }, + { + // ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4. + matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe, + toHextets: (parts) => [parts[6], parts[7]], + }, +]; + +function stripIpv6Brackets(value: string): string { + if (value.startsWith("[") && value.endsWith("]")) { + return value.slice(1, -1); + } + return value; +} + +export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 { + return address.kind() === "ipv4"; +} + +export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 { + return address.kind() === "ipv6"; +} + +function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress { + if (!isIpv6Address(address)) { + return address; + } + if (!address.isIPv4MappedAddress()) { + return address; + } + return address.toIPv4Address(); +} + +export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized) { + return undefined; + } + if (ipaddr.IPv4.isValid(normalized)) { + if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) { + return undefined; + } + return ipaddr.IPv4.parse(normalized); + } + if (ipaddr.IPv6.isValid(normalized)) { + return ipaddr.IPv6.parse(normalized); + } + return undefined; +} + +export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized) { + return undefined; + } + if (!ipaddr.isValid(normalized)) { + return undefined; + } + return ipaddr.parse(normalized); +} + +export function normalizeIpAddress(raw: string | undefined): string | undefined { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return undefined; + } + const normalized = normalizeIpv4MappedAddress(parsed); + return normalized.toString().toLowerCase(); +} + +export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { + const trimmed = raw?.trim(); + if (!trimmed) { + return false; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized) { + return false; + } + return ipaddr.IPv4.isValidFourPartDecimal(normalized); +} + +export function isLegacyIpv4Literal(raw: string | undefined): boolean { + const trimmed = raw?.trim(); + if (!trimmed) { + return false; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized || normalized.includes(":")) { + return false; + } + return ipaddr.IPv4.isValid(normalized) && !ipaddr.IPv4.isValidFourPartDecimal(normalized); +} + +export function isLoopbackIpAddress(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + return normalized.range() === "loopback"; +} + +export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + if (isIpv4Address(normalized)) { + return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range()); + } + if (PRIVATE_OR_LOOPBACK_IPV6_RANGES.has(normalized.range())) { + return true; + } + // ipaddr.js does not classify deprecated site-local fec0::/10 as private. + return (normalized.parts[0] & 0xffc0) === 0xfec0; +} + +export function isRfc1918Ipv4Address(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed || !isIpv4Address(parsed)) { + return false; + } + return parsed.range() === "private"; +} + +export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed || !isIpv4Address(parsed)) { + return false; + } + return parsed.range() === "carrierGradeNat"; +} + +export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()); +} + +function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { + const octets: [number, number, number, number] = [ + (high >>> 8) & 0xff, + high & 0xff, + (low >>> 8) & 0xff, + low & 0xff, + ]; + return ipaddr.IPv4.parse(octets.join(".")); +} + +export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined { + if (address.isIPv4MappedAddress()) { + return address.toIPv4Address(); + } + if (address.range() === "rfc6145") { + return decodeIpv4FromHextets(address.parts[6], address.parts[7]); + } + if (address.range() === "rfc6052") { + return decodeIpv4FromHextets(address.parts[6], address.parts[7]); + } + for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) { + if (!rule.matches(address.parts)) { + continue; + } + const [high, low] = rule.toHextets(address.parts); + return decodeIpv4FromHextets(high, low); + } + return undefined; +} + +export function isIpInCidr(ip: string, cidr: string): boolean { + const normalizedIp = parseCanonicalIpAddress(ip); + if (!normalizedIp) { + return false; + } + const candidate = cidr.trim(); + if (!candidate) { + return false; + } + const comparableIp = normalizeIpv4MappedAddress(normalizedIp); + if (!candidate.includes("/")) { + const exact = parseCanonicalIpAddress(candidate); + if (!exact) { + return false; + } + const comparableExact = normalizeIpv4MappedAddress(exact); + return ( + comparableIp.kind() === comparableExact.kind() && + comparableIp.toString() === comparableExact.toString() + ); + } + + let parsedCidr: [ParsedIpAddress, number]; + try { + parsedCidr = ipaddr.parseCIDR(candidate); + } catch { + return false; + } + + const [baseAddress, prefixLength] = parsedCidr; + const comparableBase = normalizeIpv4MappedAddress(baseAddress); + if (comparableIp.kind() !== comparableBase.kind()) { + return false; + } + try { + return comparableIp.match([comparableBase, prefixLength]); + } catch { + return false; + } +}
src/shared/net/ipv4.ts+4 −12 modified@@ -1,21 +1,13 @@ +import { isCanonicalDottedDecimalIPv4 } from "./ip.js"; + export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined { if (!value) { return "IP address is required for custom bind mode"; } - const trimmed = value.trim(); - const parts = trimmed.split("."); - if (parts.length !== 4) { - return "Invalid IPv4 address (e.g., 192.168.1.100)"; - } - if ( - parts.every((part) => { - const n = parseInt(part, 10); - return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); - }) - ) { + if (isCanonicalDottedDecimalIPv4(value)) { return undefined; } - return "Invalid IPv4 address (each octet must be 0-255)"; + return "Invalid IPv4 address (e.g., 192.168.1.100)"; } // Backward-compatible alias for callers using the old helper name.
44dfbd23df45fix(ssrf): centralize host/ip block checks
3 files changed · +33 −11
src/gateway/server-cron.test.ts+1 −1 modified@@ -99,7 +99,7 @@ describe("buildGatewayCronService", () => { loadConfigMock.mockReturnValue(cfg); fetchWithSsrFGuardMock.mockRejectedValue( - new SsrFBlockedError("Blocked: private/internal IP address"), + new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"), ); const state = buildGatewayCronService({
src/infra/net/ssrf.pinning.test.ts+5 −2 modified@@ -49,8 +49,11 @@ describe("ssrf pinning", () => { ); }); - it("rejects private DNS results", async () => { - const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]) as unknown as LookupFn; + it.each([ + { 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); });
src/infra/net/ssrf.ts+27 −8 modified@@ -316,6 +316,8 @@ const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [ { 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); function isBlockedIpv4SpecialUse(parts: number[]): boolean { @@ -430,6 +432,24 @@ export function isBlockedHostnameOrIp(hostname: string): boolean { 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: { hostname: string; addresses: string[]; @@ -506,13 +526,15 @@ export async function resolvePinnedHostnameWithPolicy( const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); const isExplicitAllowed = allowedHostnames.has(normalized); + const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed; if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) { throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); } - if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) { - throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"); + if (!skipPrivateNetworkChecks) { + // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. + assertAllowedHostOrIpOrThrow(normalized); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -521,12 +543,9 @@ export async function resolvePinnedHostnameWithPolicy( throw new Error(`Unable to resolve hostname: ${hostname}`); } - if (!allowPrivateNetwork && !isExplicitAllowed) { - for (const entry of results) { - if (isPrivateIpAddress(entry.address)) { - throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"); - } - } + if (!skipPrivateNetworkChecks) { + // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. + assertAllowedResolvedAddressesOrThrow(results); } const addresses = Array.from(new Set(results.map((entry) => entry.address)));
71bd15bb4294fix(ssrf): block special-use ipv4 ranges
4 files changed · +90 −25
CHANGELOG.md+1 −0 modified@@ -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.
src/infra/net/fetch-guard.ssrf.test.ts+11 −0 modified@@ -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 },
src/infra/net/ssrf.test.ts+26 −0 modified@@ -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);
src/infra/net/ssrf.ts+52 −25 modified@@ -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; - } - 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; +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 === 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"); } } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/openclaw/openclaw/commit/333fbb86347998526dd514290adfd5f727caa6d9nvdPatchWEB
- github.com/openclaw/openclaw/commit/44dfbd23df453e51b71ef79a148c28c53e89168cnvdPatchWEB
- github.com/openclaw/openclaw/commit/71bd15bb4294d3d1b54386064d69cd0f5f731bd8nvdPatchWEB
- github.com/openclaw/openclaw/commit/f14ebd743cfc73f667fae80af70043d0ab1f88bdnvdPatchWEB
- github.com/advisories/GHSA-4rqq-w8v4-7p47ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-4rqq-w8v4-7p47nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32019ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-incomplete-ipv4-special-use-range-blocking-in-ssrf-guardnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.