mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix: block ISATAP SSRF bypass via shared host/ip guard
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T>(accountId: string, fn: () => Promise<T>): 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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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[];
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user