mirror of https://github.com/openclaw/openclaw.git
fix(ssrf): block special-use ipv4 ranges
This commit is contained in:
parent
2f46308d5a
commit
71bd15bb42
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue