fix(ssrf): block special-use ipv4 ranges

This commit is contained in:
Peter Steinberger 2026-02-21 23:44:52 +01:00
parent 2f46308d5a
commit 71bd15bb42
4 changed files with 90 additions and 25 deletions

View File

@ -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.

View File

@ -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 },

View File

@ -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);

View File

@ -279,32 +279,59 @@ function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null {
return null;
}
function isPrivateIpv4(parts: number[]): boolean {
const [octet1, octet2] = parts;
if (octet1 === 0) {
return true;
type Ipv4Cidr = {
base: readonly [number, number, number, number];
prefixLength: number;
};
function ipv4ToUint(parts: readonly number[]): number {
const [a, b, c, d] = parts;
return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0;
}
function ipv4RangeFromCidr(cidr: Ipv4Cidr): readonly [start: number, end: number] {
const base = ipv4ToUint(cidr.base);
const hostBits = 32 - cidr.prefixLength;
const mask = cidr.prefixLength === 0 ? 0 : (0xffffffff << hostBits) >>> 0;
const start = (base & mask) >>> 0;
const end = (start | (~mask >>> 0)) >>> 0;
return [start, end];
}
const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [
{ base: [0, 0, 0, 0], prefixLength: 8 },
{ base: [10, 0, 0, 0], prefixLength: 8 },
{ base: [100, 64, 0, 0], prefixLength: 10 },
{ base: [127, 0, 0, 0], prefixLength: 8 },
{ base: [169, 254, 0, 0], prefixLength: 16 },
{ base: [172, 16, 0, 0], prefixLength: 12 },
{ base: [192, 0, 0, 0], prefixLength: 24 },
{ base: [192, 0, 2, 0], prefixLength: 24 },
{ base: [192, 88, 99, 0], prefixLength: 24 },
{ base: [192, 168, 0, 0], prefixLength: 16 },
{ base: [198, 18, 0, 0], prefixLength: 15 },
{ base: [198, 51, 100, 0], prefixLength: 24 },
{ base: [203, 0, 113, 0], prefixLength: 24 },
{ base: [224, 0, 0, 0], prefixLength: 4 },
{ base: [240, 0, 0, 0], prefixLength: 4 },
];
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
if (parts.length !== 4) {
return false;
}
if (octet1 === 10) {
return true;
}
if (octet1 === 127) {
return true;
}
if (octet1 === 169 && octet2 === 254) {
return true;
}
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) {
return true;
}
if (octet1 === 192 && octet2 === 168) {
return true;
}
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) {
return true;
const value = ipv4ToUint(parts);
for (const [start, end] of BLOCKED_IPV4_SPECIAL_USE_RANGES) {
if (value >= start && value <= end) {
return true;
}
}
return false;
}
// Returns true for private/internal and special-use non-global addresses.
export function isPrivateIpAddress(address: string): boolean {
let normalized = address.trim().toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) {
@ -345,7 +372,7 @@ export function isPrivateIpAddress(address: string): boolean {
const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets);
if (embeddedIpv4) {
return isPrivateIpv4(embeddedIpv4);
return isBlockedIpv4SpecialUse(embeddedIpv4);
}
// IPv6 private/internal ranges
@ -367,7 +394,7 @@ export function isPrivateIpAddress(address: string): boolean {
const ipv4 = parseIpv4(normalized);
if (ipv4) {
return isPrivateIpv4(ipv4);
return isBlockedIpv4SpecialUse(ipv4);
}
// Reject non-canonical IPv4 literal forms (octal/hex/short/packed) by default.
if (isUnsupportedLegacyIpv4Literal(normalized)) {
@ -485,7 +512,7 @@ export async function resolvePinnedHostnameWithPolicy(
}
if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) {
throw new SsrFBlockedError("Blocked hostname or private/internal IP address");
throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address");
}
const lookupFn = params.lookupFn ?? dnsLookup;
@ -497,7 +524,7 @@ export async function resolvePinnedHostnameWithPolicy(
if (!allowPrivateNetwork && !isExplicitAllowed) {
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address");
}
}
}