fix(security): block full-form IPv4-mapped IPv6 in SSRF guard

This commit is contained in:
Peter Steinberger
2026-02-14 22:56:08 +01:00
parent 2954cdabf9
commit c0c0e0f9ae
3 changed files with 149 additions and 33 deletions

View File

@@ -20,7 +20,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec.
- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isPrivateIpAddress } from "./ssrf.js";
describe("ssrf ip classification", () => {
it("treats IPv4-mapped and IPv4-compatible IPv6 loopback as private", () => {
expect(isPrivateIpAddress("::ffff:127.0.0.1")).toBe(true);
expect(isPrivateIpAddress("0:0:0:0:0:ffff:7f00:1")).toBe(true);
expect(isPrivateIpAddress("0000:0000:0000:0000:0000:ffff:7f00:0001")).toBe(true);
expect(isPrivateIpAddress("::127.0.0.1")).toBe(true);
expect(isPrivateIpAddress("0:0:0:0:0:0:7f00:1")).toBe(true);
expect(isPrivateIpAddress("[0:0:0:0:0:ffff:7f00:1]")).toBe(true);
});
it("treats IPv4-mapped metadata/link-local as private", () => {
expect(isPrivateIpAddress("::ffff:169.254.169.254")).toBe(true);
expect(isPrivateIpAddress("0:0:0:0:0:ffff:a9fe:a9fe")).toBe(true);
});
it("treats common IPv6 private/internal ranges as private", () => {
expect(isPrivateIpAddress("::")).toBe(true);
expect(isPrivateIpAddress("::1")).toBe(true);
expect(isPrivateIpAddress("fe80::1%lo0")).toBe(true);
expect(isPrivateIpAddress("fd00::1")).toBe(true);
expect(isPrivateIpAddress("fec0::1")).toBe(true);
});
it("does not classify public IPs as private", () => {
expect(isPrivateIpAddress("93.184.216.34")).toBe(false);
expect(isPrivateIpAddress("2606:4700:4700::1111")).toBe(false);
expect(isPrivateIpAddress("2001:db8::1")).toBe(false);
});
});

View File

@@ -23,7 +23,6 @@ export type SsrFPolicy = {
hostnameAllowlist?: string[];
};
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
function normalizeHostname(hostname: string): string {
@@ -84,35 +83,85 @@ function parseIpv4(address: string): number[] | null {
return numbers;
}
function parseIpv4FromMappedIpv6(mapped: string): number[] | null {
if (mapped.includes(".")) {
return parseIpv4(mapped);
function stripIpv6ZoneId(address: string): string {
const index = address.indexOf("%");
return index >= 0 ? address.slice(0, index) : address;
}
function parseIpv6Hextets(address: string): number[] | null {
let input = stripIpv6ZoneId(address.trim().toLowerCase());
if (!input) {
return null;
}
const parts = mapped.split(":").filter(Boolean);
if (parts.length === 1) {
const value = Number.parseInt(parts[0], 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) {
// Handle IPv4-embedded IPv6 like ::ffff:127.0.0.1 by converting the tail to 2 hextets.
if (input.includes(".")) {
const lastColon = input.lastIndexOf(":");
if (lastColon < 0) {
return null;
}
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
const ipv4 = parseIpv4(input.slice(lastColon + 1));
if (!ipv4) {
return null;
}
const high = (ipv4[0] << 8) + ipv4[1];
const low = (ipv4[2] << 8) + ipv4[3];
input = `${input.slice(0, lastColon)}:${high.toString(16)}:${low.toString(16)}`;
}
if (parts.length !== 2) {
const doubleColonParts = input.split("::");
if (doubleColonParts.length > 2) {
return null;
}
const high = Number.parseInt(parts[0], 16);
const low = Number.parseInt(parts[1], 16);
if (
Number.isNaN(high) ||
Number.isNaN(low) ||
high < 0 ||
low < 0 ||
high > 0xffff ||
low > 0xffff
) {
const headParts =
doubleColonParts[0]?.length > 0 ? doubleColonParts[0].split(":").filter(Boolean) : [];
const tailParts =
doubleColonParts.length === 2 && doubleColonParts[1]?.length > 0
? doubleColonParts[1].split(":").filter(Boolean)
: [];
const missingParts = 8 - headParts.length - tailParts.length;
if (missingParts < 0) {
return null;
}
const value = (high << 16) + low;
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
const fullParts =
doubleColonParts.length === 1
? input.split(":")
: [...headParts, ...Array.from({ length: missingParts }, () => "0"), ...tailParts];
if (fullParts.length !== 8) {
return null;
}
const hextets: number[] = [];
for (const part of fullParts) {
if (!part) {
return null;
}
const value = Number.parseInt(part, 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff) {
return null;
}
hextets.push(value);
}
return hextets;
}
function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null {
// IPv4-mapped: ::ffff:a.b.c.d (and full-form variants)
// IPv4-compatible: ::a.b.c.d (deprecated, but still needs private-network blocking)
const zeroPrefix = hextets[0] === 0 && hextets[1] === 0 && hextets[2] === 0 && hextets[3] === 0;
if (!zeroPrefix || hextets[4] !== 0) {
return null;
}
if (hextets[5] !== 0xffff && hextets[5] !== 0) {
return null;
}
const high = hextets[6];
const low = hextets[7];
return [(high >>> 8) & 0xff, high & 0xff, (low >>> 8) & 0xff, low & 0xff];
}
function isPrivateIpv4(parts: number[]): boolean {
@@ -150,19 +199,54 @@ export function isPrivateIpAddress(address: string): boolean {
return false;
}
if (normalized.startsWith("::ffff:")) {
const mapped = normalized.slice("::ffff:".length);
const ipv4 = parseIpv4FromMappedIpv6(mapped);
if (ipv4) {
return isPrivateIpv4(ipv4);
}
}
if (normalized.includes(":")) {
if (normalized === "::" || normalized === "::1") {
const hextets = parseIpv6Hextets(normalized);
if (!hextets) {
return false;
}
const isUnspecified =
hextets[0] === 0 &&
hextets[1] === 0 &&
hextets[2] === 0 &&
hextets[3] === 0 &&
hextets[4] === 0 &&
hextets[5] === 0 &&
hextets[6] === 0 &&
hextets[7] === 0;
const isLoopback =
hextets[0] === 0 &&
hextets[1] === 0 &&
hextets[2] === 0 &&
hextets[3] === 0 &&
hextets[4] === 0 &&
hextets[5] === 0 &&
hextets[6] === 0 &&
hextets[7] === 1;
if (isUnspecified || isLoopback) {
return true;
}
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
const embeddedIpv4 = extractIpv4FromEmbeddedIpv6(hextets);
if (embeddedIpv4) {
return isPrivateIpv4(embeddedIpv4);
}
// IPv6 private/internal ranges
// - link-local: fe80::/10
// - site-local (deprecated, but internal): fec0::/10
// - unique local: fc00::/7
const first = hextets[0];
if ((first & 0xffc0) === 0xfe80) {
return true;
}
if ((first & 0xffc0) === 0xfec0) {
return true;
}
if ((first & 0xfe00) === 0xfc00) {
return true;
}
return false;
}
const ipv4 = parseIpv4(normalized);