VYPR
High severity7.4NVD Advisory· Published Mar 19, 2026· Updated Apr 20, 2026

CVE-2026-32019

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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.2.22

Patches

4
f14ebd743cfc

refactor(security): unify local-host and tailnet CIDR checks

https://github.com/openclaw/openclawPeter SteinbergerFeb 22, 2026via ghsa
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 {
    
333fbb863479

refactor(net): consolidate IP checks with ipaddr.js

https://github.com/openclaw/openclawPeter SteinbergerFeb 22, 2026via ghsa
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.
    
44dfbd23df45

fix(ssrf): centralize host/ip block checks

https://github.com/openclaw/openclawPeter SteinbergerFeb 22, 2026via ghsa
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)));
    
71bd15bb4294

fix(ssrf): block special-use ipv4 ranges

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.