From d5ae4b83378dbe8969c3422df0e7cab622688f58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 16:37:15 +0000 Subject: [PATCH] fix(gateway): require local client for loopback origin fallback --- src/gateway/origin-check.test.ts | 13 ++++++++++++ src/gateway/origin-check.ts | 20 ++++++++++++------- src/gateway/server-runtime-state.ts | 6 ++++++ .../server/ws-connection/message-handler.ts | 18 +++++++++++++++-- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index a239e7e6f78..50c031e927d 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -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", diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index 0900ed678d0..d6795a7b64e 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -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" }; diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 100836def45..46111c99c53 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -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) { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a28ff379000..58b5c9c2ab4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -90,6 +90,7 @@ type SubsystemLogger = ReturnType; 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;