From d51929ecb52fe65e90bf36795f4247feb29eb8aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 09:59:34 +0100 Subject: [PATCH] fix: block ISATAP SSRF bypass via shared host/ip guard --- CHANGELOG.md | 1 + .../nostr/src/nostr-profile-http.test.ts | 17 ++++ extensions/nostr/src/nostr-profile-http.ts | 85 ++----------------- extensions/tlon/src/urbit/base-url.ts | 4 +- src/infra/net/ssrf.test.ts | 18 +++- src/infra/net/ssrf.ts | 20 ++++- src/link-understanding/detect.test.ts | 2 + src/link-understanding/detect.ts | 14 +-- src/plugin-sdk/index.ts | 7 +- 9 files changed, 72 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c0ff6af0..bf07379c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai - BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads. Thanks @tyler6204. - Security/Exec: fix the OC-09 credential-theft path via environment-variable injection. (#18048) Thanks @aether-ai-agent. - Security/Config: confine `$include` resolution to the top-level config directory, harden traversal/symlink checks with cross-platform-safe path containment, and add doctor hints for invalid escaped include paths. (#18652) Thanks @aether-ai-agent. +- Security/Net: block SSRF bypass via ISATAP embedded IPv4 transition addresses and centralize hostname/IP blocking checks across URL safety validators. Thanks @zpbrent for reporting. - Providers: improve error messaging for unconfigured local `ollama`/`vllm` providers. (#18183) Thanks @arosstale. - TTS: surface all provider errors instead of only the last error in aggregated failures. (#17964) Thanks @ikari-pl. - CLI/Doctor/Configure: skip gateway auth checks for loopback-only setups. (#18407) Thanks @sggolakiya. diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index c7f93e57a6..d0c1c30ac8 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -279,6 +279,23 @@ describe("nostr-profile-http", () => { expect(data.error).toContain("private"); }); + it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + }); + it("rejects non-https URLs", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index b6887a01b0..082b67b449 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -8,7 +8,11 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk"; +import { + isBlockedHostnameOrIp, + readJsonBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk"; import { z } from "zod"; import { publishNostrProfile, getNostrProfileState } from "./channel.js"; import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; @@ -98,72 +102,6 @@ async function withPublishLock(accountId: string, fn: () => Promise): Prom // SSRF Protection // ============================================================================ -// Block common private/internal hostnames (quick string check) -const BLOCKED_HOSTNAMES = new Set([ - "localhost", - "localhost.localdomain", - "127.0.0.1", - "::1", - "[::1]", - "0.0.0.0", -]); - -// Check if an IP address (resolved) is in a private range -function isPrivateIp(ip: string): boolean { - // Handle IPv4 - const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); - if (ipv4Match) { - const [, a, b] = ipv4Match.map(Number); - // 127.0.0.0/8 (loopback) - if (a === 127) { - return true; - } - // 10.0.0.0/8 (private) - if (a === 10) { - return true; - } - // 172.16.0.0/12 (private) - if (a === 172 && b >= 16 && b <= 31) { - return true; - } - // 192.168.0.0/16 (private) - if (a === 192 && b === 168) { - return true; - } - // 169.254.0.0/16 (link-local) - if (a === 169 && b === 254) { - return true; - } - // 0.0.0.0/8 - if (a === 0) { - return true; - } - return false; - } - - // Handle IPv6 - const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, ""); - // ::1 (loopback) - if (ipLower === "::1") { - return true; - } - // fe80::/10 (link-local) - if (ipLower.startsWith("fe80:")) { - return true; - } - // fc00::/7 (unique local) - if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) { - return true; - } - // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4 - const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (v4Mapped) { - return isPrivateIp(v4Mapped[1]); - } - - return false; -} - function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } { try { const url = new URL(urlStr); @@ -174,18 +112,7 @@ function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: s const hostname = url.hostname.toLowerCase(); - // Quick hostname block check - if (BLOCKED_HOSTNAMES.has(hostname)) { - return { ok: false, error: "URL must not point to private/internal addresses" }; - } - - // Check if hostname is an IP address directly - if (isPrivateIp(hostname)) { - return { ok: false, error: "URL must not point to private/internal addresses" }; - } - - // Block suspicious TLDs that resolve to localhost - if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) { + if (isBlockedHostnameOrIp(hostname)) { return { ok: false, error: "URL must not point to private/internal addresses" }; } diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index 7aa85e44ce..d18832bdd1 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,4 +1,4 @@ -import { isBlockedHostname, isPrivateIpAddress } from "openclaw/plugin-sdk"; +import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } @@ -53,5 +53,5 @@ export function isBlockedUrbitHostname(hostname: string): boolean { if (!normalized) { return false; } - return isBlockedHostname(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameOrIp(normalized); } diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 4a41eafa15..521e1f42a6 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { normalizeFingerprint } from "../tls/fingerprint.js"; -import { isPrivateIpAddress } from "./ssrf.js"; +import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ "::ffff:127.0.0.1", @@ -24,6 +24,8 @@ const privateIpCases = [ "fe80::1%lo0", "fd00::1", "fec0::1", + "2001:db8:1234::5efe:127.0.0.1", + "2001:db8:1234:1:200:5efe:7f00:1", ]; const publicIpCases = [ @@ -34,6 +36,8 @@ const publicIpCases = [ "64:ff9b:1::8.8.8.8", "2002:0808:0808::", "2001:0000:0:0:0:0:f7f7:f7f7", + "2001:db8:1234::5efe:8.8.8.8", + "2001:db8:1234:1:1111:5efe:7f00:1", ]; const malformedIpv6Cases = ["::::", "2001:db8::gggg"]; @@ -59,3 +63,15 @@ describe("normalizeFingerprint", () => { expect(normalizeFingerprint("aa:bb:cc")).toBe("aabbcc"); }); }); + +describe("isBlockedHostnameOrIp", () => { + it("blocks localhost.localdomain and metadata hostname aliases", () => { + expect(isBlockedHostnameOrIp("localhost.localdomain")).toBe(true); + expect(isBlockedHostnameOrIp("metadata.google.internal")).toBe(true); + }); + + it("blocks private transition addresses via shared IP classifier", () => { + expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); + }); +}); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 67d84b2847..33207d108f 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -24,7 +24,11 @@ export type SsrFPolicy = { hostnameAllowlist?: string[]; }; -const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]); +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "metadata.google.internal", +]); function normalizeHostnameSet(values?: string[]): Set { if (!values || values.length === 0) { @@ -195,6 +199,12 @@ const EMBEDDED_IPV4_RULES: EmbeddedIpv4Rule[] = [ 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 { @@ -316,6 +326,14 @@ export function isBlockedHostname(hostname: string): boolean { ); } +export function isBlockedHostnameOrIp(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + if (!normalized) { + return false; + } + return isBlockedHostname(normalized) || isPrivateIpAddress(normalized); +} + export function createPinnedLookup(params: { hostname: string; addresses: string[]; diff --git a/src/link-understanding/detect.test.ts b/src/link-understanding/detect.test.ts index c7f2ee83ab..25747c48da 100644 --- a/src/link-understanding/detect.test.ts +++ b/src/link-understanding/detect.test.ts @@ -26,6 +26,7 @@ describe("extractLinksFromMessage", () => { it("blocks localhost and common loopback addresses", () => { expect(extractLinksFromMessage("http://localhost/secret")).toEqual([]); + expect(extractLinksFromMessage("http://localhost.localdomain/secret")).toEqual([]); expect(extractLinksFromMessage("http://foo.localhost/secret")).toEqual([]); expect(extractLinksFromMessage("http://service.local/secret")).toEqual([]); expect(extractLinksFromMessage("http://service.internal/secret")).toEqual([]); @@ -53,6 +54,7 @@ describe("extractLinksFromMessage", () => { it("blocks private and mapped IPv6 addresses", () => { expect(extractLinksFromMessage("http://[::ffff:127.0.0.1]/secret")).toEqual([]); + expect(extractLinksFromMessage("http://[2001:db8:1234::5efe:127.0.0.1]/secret")).toEqual([]); expect(extractLinksFromMessage("http://[fe80::1]/secret")).toEqual([]); expect(extractLinksFromMessage("http://[fc00::1]/secret")).toEqual([]); }); diff --git a/src/link-understanding/detect.ts b/src/link-understanding/detect.ts index 5c2a74e3f2..365685626e 100644 --- a/src/link-understanding/detect.ts +++ b/src/link-understanding/detect.ts @@ -1,4 +1,4 @@ -import { isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js"; +import { isBlockedHostnameOrIp } from "../infra/net/ssrf.js"; import { DEFAULT_MAX_LINKS } from "./defaults.js"; // Remove markdown link syntax so only bare URLs are considered. @@ -22,7 +22,7 @@ function isAllowedUrl(raw: string): boolean { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return false; } - if (isBlockedHost(parsed.hostname)) { + if (isBlockedHostnameOrIp(parsed.hostname)) { return false; } return true; @@ -31,16 +31,6 @@ function isAllowedUrl(raw: string): boolean { } } -/** Block loopback, private, link-local, and metadata addresses. */ -function isBlockedHost(hostname: string): boolean { - const normalized = hostname.trim().toLowerCase(); - return ( - normalized === "localhost.localdomain" || - isBlockedHostname(normalized) || - isPrivateIpAddress(normalized) - ); -} - export function extractLinksFromMessage(message: string, opts?: { maxLinks?: number }): string[] { const source = message?.trim(); if (!source) { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 47ef9f2479..b674d985bb 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -181,7 +181,12 @@ export { } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -export { SsrFBlockedError, isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js"; +export { + SsrFBlockedError, + isBlockedHostname, + isBlockedHostnameOrIp, + isPrivateIpAddress, +} from "../infra/net/ssrf.js"; export type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js"; export { rawDataToString } from "../infra/ws.js"; export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js";