diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf074a12c7..722fbb24201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Net: harden SSRF IPv4 literal parsing to block octal/hex/short/packed legacy forms (for example `0177.0.0.1`, `127.1`, `2130706433`) in pre-DNS guard checks. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index a4722d2b26d..fe6e60a59df 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -22,6 +22,17 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("blocks legacy loopback literal URLs before fetch", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "http://0177.0.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 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 5196514566b..14ee88bc49e 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -122,6 +122,17 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("blocks legacy loopback IPv4 literals before DNS lookup", async () => { + const lookup = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + + await expect( + resolvePinnedHostnameWithPolicy("0177.0.0.1", { lookupFn: lookup }), + ).rejects.toThrow(SsrFBlockedError); + expect(lookup).not.toHaveBeenCalled(); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 521e1f42a6e..f2f571c3708 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -26,6 +26,12 @@ const privateIpCases = [ "fec0::1", "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", + "0177.0.0.1", + "0x7f.0.0.1", + "127.1", + "2130706433", + "0x7f000001", + "017700000001", ]; const publicIpCases = [ @@ -38,9 +44,12 @@ const publicIpCases = [ "2001:0000:0:0:0:0:f7f7:f7f7", "2001:db8:1234::5efe:8.8.8.8", "2001:db8:1234:1:1111:5efe:7f00:1", + "8.8.2056", + "0x08080808", ]; const malformedIpv6Cases = ["::::", "2001:db8::gggg"]; +const malformedIpv4Cases = ["08.0.0.1", "0x7g.0.0.1", "127.0.0.1.", "127..0.1"]; describe("ssrf ip classification", () => { it.each(privateIpCases)("classifies %s as private", (address) => { @@ -54,6 +63,10 @@ describe("ssrf ip classification", () => { it.each(malformedIpv6Cases)("fails closed for malformed IPv6 %s", (address) => { expect(isPrivateIpAddress(address)).toBe(true); }); + + it.each(malformedIpv4Cases)("treats malformed IPv4 literal %s as non-IP", (address) => { + expect(isPrivateIpAddress(address)).toBe(false); + }); }); describe("normalizeFingerprint", () => { @@ -74,4 +87,10 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true); expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); + + it("blocks legacy IPv4 literal representations", () => { + expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("127.1")).toBe(true); + expect(isBlockedHostnameOrIp("2130706433")).toBe(true); + }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 90ed62cf12e..d3d621b2542 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -70,14 +70,74 @@ function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolea function parseIpv4(address: string): number[] | null { const parts = address.split("."); - if (parts.length !== 4) { + if (parts.length < 1 || parts.length > 4) { return null; } - const numbers = parts.map((part) => Number.parseInt(part, 10)); - if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) { + + const numbers: number[] = []; + for (const part of parts) { + if (!part) { + return null; + } + const lower = part.toLowerCase(); + let value: number; + if (lower.startsWith("0x")) { + const hex = lower.slice(2); + if (!hex || !/^[0-9a-f]+$/i.test(hex)) { + return null; + } + value = Number.parseInt(hex, 16); + } else if (part.length > 1 && part.startsWith("0")) { + const octal = part.slice(1); + if (!/^[0-7]+$/.test(octal)) { + return null; + } + value = Number.parseInt(octal, 8); + } else { + if (!/^[0-9]+$/.test(part)) { + return null; + } + value = Number.parseInt(part, 10); + } + if (!Number.isFinite(value) || value < 0) { + return null; + } + numbers.push(value); + } + + let ipv4Number: number; + if (numbers.length === 1) { + if (numbers[0] > 0xffffffff) { + return null; + } + ipv4Number = numbers[0]; + } else if (numbers.length === 2) { + if (numbers[0] > 0xff || numbers[1] > 0xffffff) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1]; + } else if (numbers.length === 3) { + if (numbers[0] > 0xff || numbers[1] > 0xff || numbers[2] > 0xffff) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2]; + } else { + if (numbers.some((value) => value > 0xff)) { + return null; + } + ipv4Number = numbers[0] * 0x1000000 + numbers[1] * 0x10000 + numbers[2] * 0x100 + numbers[3]; + } + + if (!Number.isSafeInteger(ipv4Number) || ipv4Number < 0 || ipv4Number > 0xffffffff) { return null; } - return numbers; + + return [ + Math.floor(ipv4Number / 0x1000000) & 0xff, + Math.floor(ipv4Number / 0x10000) & 0xff, + Math.floor(ipv4Number / 0x100) & 0xff, + ipv4Number & 0xff, + ]; } function stripIpv6ZoneId(address: string): string {