fix: block ISATAP SSRF bypass via shared host/ip guard

This commit is contained in:
Peter Steinberger
2026-02-19 09:59:34 +01:00
parent 4cd5fad14b
commit d51929ecb5
9 changed files with 72 additions and 96 deletions

View File

@@ -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);

View File

@@ -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" };
}

View File

@@ -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);
}