mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 02:22:43 +00:00
refactor(security): unify local-host and tailnet CIDR checks
This commit is contained in:
@@ -104,4 +104,14 @@ describe("fetchBrowserJson loopback auth", () => {
|
|||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
expect(headers.get("authorization")).toBe("Bearer loopback-token");
|
expect(headers.get("authorization")).toBe("Bearer loopback-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("injects auth for IPv4-mapped IPv6 loopback URLs", async () => {
|
||||||
|
const fetchMock = stubJsonFetchOk();
|
||||||
|
|
||||||
|
await fetchBrowserJson<{ ok: boolean }>("http://[::ffff:127.0.0.1]:18888/");
|
||||||
|
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("authorization")).toBe("Bearer loopback-token");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { isLoopbackHost } from "../gateway/net.js";
|
||||||
import { getBridgeAuthForPort } from "./bridge-auth-registry.js";
|
import { getBridgeAuthForPort } from "./bridge-auth-registry.js";
|
||||||
import { resolveBrowserControlAuth } from "./control-auth.js";
|
import { resolveBrowserControlAuth } from "./control-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +21,7 @@ function isAbsoluteHttp(url: string): boolean {
|
|||||||
|
|
||||||
function isLoopbackHttpUrl(url: string): boolean {
|
function isLoopbackHttpUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const host = new URL(url).hostname.trim().toLowerCase();
|
return isLoopbackHost(new URL(url).hostname);
|
||||||
// URL hostnames may keep IPv6 brackets (for example "[::1]"); normalize before checks.
|
|
||||||
const normalizedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
||||||
return (
|
|
||||||
normalizedHost === "127.0.0.1" || normalizedHost === "localhost" || normalizedHost === "::1"
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
type RateLimitCheckResult,
|
type RateLimitCheckResult,
|
||||||
} from "./auth-rate-limit.js";
|
} from "./auth-rate-limit.js";
|
||||||
import {
|
import {
|
||||||
|
isLocalishHost,
|
||||||
isLoopbackAddress,
|
isLoopbackAddress,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
resolveHostName,
|
|
||||||
resolveClientIp,
|
resolveClientIp,
|
||||||
} from "./net.js";
|
} from "./net.js";
|
||||||
|
|
||||||
@@ -133,10 +133,6 @@ export function isLocalDirectRequest(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = resolveHostName(req.headers?.host);
|
|
||||||
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
||||||
const hostIsTailscaleServe = host.endsWith(".ts.net");
|
|
||||||
|
|
||||||
const hasForwarded = Boolean(
|
const hasForwarded = Boolean(
|
||||||
req.headers?.["x-forwarded-for"] ||
|
req.headers?.["x-forwarded-for"] ||
|
||||||
req.headers?.["x-real-ip"] ||
|
req.headers?.["x-real-ip"] ||
|
||||||
@@ -144,7 +140,7 @@ export function isLocalDirectRequest(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
||||||
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
|
return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
isLocalishHost,
|
||||||
isPrivateOrLoopbackAddress,
|
isPrivateOrLoopbackAddress,
|
||||||
isSecureWebSocketUrl,
|
isSecureWebSocketUrl,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
@@ -24,6 +25,28 @@ describe("resolveHostName", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isLocalishHost", () => {
|
||||||
|
it("accepts loopback and tailscale serve/funnel host headers", () => {
|
||||||
|
const accepted = [
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1:18789",
|
||||||
|
"[::1]:18789",
|
||||||
|
"[::ffff:127.0.0.1]:18789",
|
||||||
|
"gateway.tailnet.ts.net",
|
||||||
|
];
|
||||||
|
for (const host of accepted) {
|
||||||
|
expect(isLocalishHost(host), host).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-local hosts", () => {
|
||||||
|
const rejected = ["example.com", "192.168.1.10", "203.0.113.5:18789"];
|
||||||
|
for (const host of rejected) {
|
||||||
|
expect(isLocalishHost(host), host).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isTrustedProxyAddress", () => {
|
describe("isTrustedProxyAddress", () => {
|
||||||
describe("exact IP matching", () => {
|
describe("exact IP matching", () => {
|
||||||
it("returns true when IP matches exactly", () => {
|
it("returns true when IP matches exactly", () => {
|
||||||
|
|||||||
@@ -334,6 +334,19 @@ export function isLoopbackHost(host: string): boolean {
|
|||||||
return isLoopbackAddress(unbracket);
|
return isLoopbackAddress(unbracket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local-facing host check for inbound requests:
|
||||||
|
* - loopback hosts (localhost/127.x/::1 and mapped forms)
|
||||||
|
* - Tailscale Serve/Funnel hostnames (*.ts.net)
|
||||||
|
*/
|
||||||
|
export function isLocalishHost(hostHeader?: string): boolean {
|
||||||
|
const host = resolveHostName(hostHeader);
|
||||||
|
if (!host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isLoopbackHost(host) || host.endsWith(".ts.net");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information).
|
* Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ import {
|
|||||||
mintCanvasCapabilityToken,
|
mintCanvasCapabilityToken,
|
||||||
} from "../../canvas-capability.js";
|
} from "../../canvas-capability.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js";
|
import {
|
||||||
import { resolveHostName } from "../../net.js";
|
isLocalishHost,
|
||||||
|
isLoopbackAddress,
|
||||||
|
isTrustedProxyAddress,
|
||||||
|
resolveClientIp,
|
||||||
|
} from "../../net.js";
|
||||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||||
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
|
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
|
||||||
@@ -164,10 +168,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
||||||
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
||||||
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
||||||
const hostName = resolveHostName(requestHost);
|
const hostIsLocalish = isLocalishHost(requestHost);
|
||||||
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
|
|
||||||
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
|
|
||||||
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
|
|
||||||
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
|
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
|
||||||
const reportedClientIp =
|
const reportedClientIp =
|
||||||
isLocalClient || hasUntrustedProxyHeaders
|
isLocalClient || hasUntrustedProxyHeaders
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { isIpInCidr } from "../shared/net/ip.js";
|
||||||
|
|
||||||
export type TailnetAddresses = {
|
export type TailnetAddresses = {
|
||||||
ipv4: string[];
|
ipv4: string[];
|
||||||
ipv6: string[];
|
ipv6: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isTailnetIPv4(address: string): boolean {
|
const TAILNET_IPV4_CIDR = "100.64.0.0/10";
|
||||||
const parts = address.split(".");
|
const TAILNET_IPV6_CIDR = "fd7a:115c:a1e0::/48";
|
||||||
if (parts.length !== 4) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const octets = parts.map((p) => Number.parseInt(p, 10));
|
|
||||||
if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function isTailnetIPv4(address: string): boolean {
|
||||||
// Tailscale IPv4 range: 100.64.0.0/10
|
// Tailscale IPv4 range: 100.64.0.0/10
|
||||||
// https://tailscale.com/kb/1015/100.x-addresses
|
// https://tailscale.com/kb/1015/100.x-addresses
|
||||||
const [a, b] = octets;
|
return isIpInCidr(address, TAILNET_IPV4_CIDR);
|
||||||
return a === 100 && b >= 64 && b <= 127;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTailnetIPv6(address: string): boolean {
|
function isTailnetIPv6(address: string): boolean {
|
||||||
// Tailscale IPv6 ULA prefix: fd7a:115c:a1e0::/48
|
// Tailscale IPv6 ULA prefix: fd7a:115c:a1e0::/48
|
||||||
// (stable across tailnets; nodes get per-device suffixes)
|
// (stable across tailnets; nodes get per-device suffixes)
|
||||||
const normalized = address.trim().toLowerCase();
|
return isIpInCidr(address, TAILNET_IPV6_CIDR);
|
||||||
return normalized.startsWith("fd7a:115c:a1e0:");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listTailnetAddresses(): TailnetAddresses {
|
export function listTailnetAddresses(): TailnetAddresses {
|
||||||
|
|||||||
Reference in New Issue
Block a user