mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:56:45 +00:00
fix(gateway): require local client for loopback origin fallback
This commit is contained in:
@@ -9,6 +9,9 @@ describe("checkBrowserOrigin", () => {
|
||||
allowHostHeaderOriginFallback: true,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.matchedBy).toBe("host-header-fallback");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects same-origin host matches when legacy host-header fallback is disabled", () => {
|
||||
@@ -23,10 +26,20 @@ describe("checkBrowserOrigin", () => {
|
||||
const result = checkBrowserOrigin({
|
||||
requestHost: "127.0.0.1:18789",
|
||||
origin: "http://localhost:5173",
|
||||
isLocalClient: true,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects loopback origin mismatches when request is not local", () => {
|
||||
const result = checkBrowserOrigin({
|
||||
requestHost: "127.0.0.1:18789",
|
||||
origin: "http://localhost:5173",
|
||||
isLocalClient: false,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts allowlisted origins", () => {
|
||||
const result = checkBrowserOrigin({
|
||||
requestHost: "gateway.example.com:18789",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { isLoopbackHost, normalizeHostHeader, resolveHostName } from "./net.js";
|
||||
import { isLoopbackHost, normalizeHostHeader } from "./net.js";
|
||||
|
||||
type OriginCheckResult = { ok: true } | { ok: false; reason: string };
|
||||
type OriginCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
matchedBy: "allowlist" | "host-header-fallback" | "local-loopback";
|
||||
}
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function parseOrigin(
|
||||
originRaw?: string,
|
||||
@@ -26,6 +31,7 @@ export function checkBrowserOrigin(params: {
|
||||
origin?: string;
|
||||
allowedOrigins?: string[];
|
||||
allowHostHeaderOriginFallback?: boolean;
|
||||
isLocalClient?: boolean;
|
||||
}): OriginCheckResult {
|
||||
const parsedOrigin = parseOrigin(params.origin);
|
||||
if (!parsedOrigin) {
|
||||
@@ -36,7 +42,7 @@ export function checkBrowserOrigin(params: {
|
||||
(params.allowedOrigins ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
if (allowlist.has("*") || allowlist.has(parsedOrigin.origin)) {
|
||||
return { ok: true };
|
||||
return { ok: true, matchedBy: "allowlist" };
|
||||
}
|
||||
|
||||
const requestHost = normalizeHostHeader(params.requestHost);
|
||||
@@ -45,12 +51,12 @@ export function checkBrowserOrigin(params: {
|
||||
requestHost &&
|
||||
parsedOrigin.host === requestHost
|
||||
) {
|
||||
return { ok: true };
|
||||
return { ok: true, matchedBy: "host-header-fallback" };
|
||||
}
|
||||
|
||||
const requestHostname = resolveHostName(requestHost);
|
||||
if (isLoopbackHost(parsedOrigin.hostname) && isLoopbackHost(requestHostname)) {
|
||||
return { ok: true };
|
||||
// Dev fallback only for genuinely local socket clients, not Host-header claims.
|
||||
if (params.isLocalClient && isLoopbackHost(parsedOrigin.hostname)) {
|
||||
return { ok: true, matchedBy: "local-loopback" };
|
||||
}
|
||||
|
||||
return { ok: false, reason: "origin not allowed" };
|
||||
|
||||
@@ -130,6 +130,12 @@ export async function createGatewayRuntimeState(params: {
|
||||
"Ensure authentication is configured before exposing to public networks.",
|
||||
);
|
||||
}
|
||||
if (params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) {
|
||||
params.log.warn(
|
||||
"⚠️ gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true is enabled. " +
|
||||
"Host-header origin fallback weakens origin checks and should only be used as break-glass.",
|
||||
);
|
||||
}
|
||||
const httpServers: HttpServer[] = [];
|
||||
const httpBindHosts: string[] = [];
|
||||
for (const host of bindHosts) {
|
||||
|
||||
@@ -90,6 +90,7 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
|
||||
const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
|
||||
let hostHeaderFallbackAcceptedCount = 0;
|
||||
|
||||
type HandshakeBrowserSecurityContext = {
|
||||
hasBrowserOriginHeader: boolean;
|
||||
@@ -491,12 +492,14 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
const isWebchat = isWebchatConnect(connectParams);
|
||||
if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) {
|
||||
const hostHeaderOriginFallbackEnabled =
|
||||
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
|
||||
const originCheck = checkBrowserOrigin({
|
||||
requestHost,
|
||||
origin: requestOrigin,
|
||||
allowedOrigins: configSnapshot.gateway?.controlUi?.allowedOrigins,
|
||||
allowHostHeaderOriginFallback:
|
||||
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true,
|
||||
allowHostHeaderOriginFallback: hostHeaderOriginFallbackEnabled,
|
||||
isLocalClient,
|
||||
});
|
||||
if (!originCheck.ok) {
|
||||
const errorMessage =
|
||||
@@ -510,6 +513,17 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
close(1008, truncateCloseReason(errorMessage));
|
||||
return;
|
||||
}
|
||||
if (originCheck.matchedBy === "host-header-fallback") {
|
||||
hostHeaderFallbackAcceptedCount += 1;
|
||||
logWsControl.warn(
|
||||
`security warning: websocket origin accepted via Host-header fallback conn=${connId} count=${hostHeaderFallbackAcceptedCount} host=${requestHost ?? "n/a"} origin=${requestOrigin ?? "n/a"}`,
|
||||
);
|
||||
if (hostHeaderOriginFallbackEnabled) {
|
||||
logGateway.warn(
|
||||
"security metric: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback accepted a websocket connect request",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deviceRaw = connectParams.device;
|
||||
|
||||
Reference in New Issue
Block a user