fix(gateway): require local client for loopback origin fallback

This commit is contained in:
Peter Steinberger
2026-03-02 16:37:15 +00:00
parent 0dbb92dd2b
commit d5ae4b8337
4 changed files with 48 additions and 9 deletions

View File

@@ -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",

View File

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

View File

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

View File

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