Gateway: allow Docker loopback Control UI pairing

This commit is contained in:
Peter Steinberger
2026-04-07 07:43:09 +01:00
parent 1c3f82dcef
commit b081f88952
3 changed files with 181 additions and 2 deletions

View File

@@ -107,6 +107,15 @@ describe("handshake auth helpers", () => {
reason: "scope-upgrade",
}),
).toBe(true);
expect(
shouldAllowSilentLocalPairing({
locality: "browser_container_local",
hasBrowserOriginHeader: true,
isControlUi: true,
isWebchat: true,
reason: "not-paired",
}),
).toBe(true);
expect(
shouldAllowSilentLocalPairing({
locality: "direct_local",
@@ -150,6 +159,112 @@ describe("handshake auth helpers", () => {
).toBe("direct_local");
});
it("classifies Docker-published loopback Control UI as browser-container-local", () => {
const connectParams = {
client: {
id: GATEWAY_CLIENT_IDS.CONTROL_UI,
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
} as ConnectParams;
expect(
resolvePairingLocality({
connectParams,
isLocalClient: false,
requestHost: "127.0.0.1:18789",
requestOrigin: "http://127.0.0.1:18789",
remoteAddress: "172.17.0.1",
hasProxyHeaders: false,
hasBrowserOriginHeader: true,
sharedAuthOk: true,
authMethod: "token",
}),
).toBe("browser_container_local");
expect(
resolvePairingLocality({
connectParams,
isLocalClient: false,
requestHost: "localhost:18789",
requestOrigin: "http://localhost:18789",
remoteAddress: "172.17.0.1",
hasProxyHeaders: false,
hasBrowserOriginHeader: true,
sharedAuthOk: true,
authMethod: "password",
}),
).toBe("browser_container_local");
});
it("keeps Docker-published non-loopback Control UI origins remote", () => {
const connectParams = {
client: {
id: GATEWAY_CLIENT_IDS.CONTROL_UI,
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
} as ConnectParams;
const base = {
connectParams,
isLocalClient: false,
remoteAddress: "172.17.0.1",
hasProxyHeaders: false,
hasBrowserOriginHeader: true,
sharedAuthOk: true,
authMethod: "token" as const,
};
expect(
resolvePairingLocality({
...base,
requestHost: "192.168.1.10:18789",
requestOrigin: "http://192.168.1.10:18789",
}),
).toBe("remote");
expect(
resolvePairingLocality({
...base,
requestHost: "127.0.0.1:18789",
requestOrigin: "https://app.example",
}),
).toBe("remote");
expect(
resolvePairingLocality({
...base,
requestHost: "127.0.0.1:18789",
requestOrigin: "http://127.0.0.1:18789",
hasProxyHeaders: true,
}),
).toBe("remote");
expect(
resolvePairingLocality({
...base,
requestHost: "127.0.0.1:18789",
requestOrigin: "http://127.0.0.1:18789",
sharedAuthOk: false,
}),
).toBe("remote");
});
it("keeps non-Control-UI clients remote for browser-container-local conditions", () => {
const connectParams = {
client: {
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
} as ConnectParams;
expect(
resolvePairingLocality({
connectParams,
isLocalClient: false,
requestHost: "127.0.0.1:18789",
requestOrigin: "http://127.0.0.1:18789",
remoteAddress: "172.17.0.1",
hasProxyHeaders: false,
hasBrowserOriginHeader: true,
sharedAuthOk: true,
authMethod: "token",
}),
).toBe("remote");
});
it("classifies CLI loopback/private-host connects as cli_container_local only with shared auth", () => {
const connectParams = {
client: {

View File

@@ -2,14 +2,24 @@ import { verifyDeviceSignature } from "../../../infra/device-identity.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult } from "../../auth.js";
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
import { isLoopbackAddress, isPrivateOrLoopbackHost, resolveHostName } from "../../net.js";
import {
isLoopbackAddress,
isLoopbackHost,
isPrivateOrLoopbackAddress,
isPrivateOrLoopbackHost,
resolveHostName,
} from "../../net.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
import type { ConnectParams } from "../../protocol/index.js";
import type { AuthProvidedKind } from "./auth-messages.js";
export const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
export const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
export type PairingLocalityKind = "direct_local" | "cli_container_local" | "remote";
export type PairingLocalityKind =
| "direct_local"
| "cli_container_local"
| "browser_container_local"
| "remote";
export type HandshakeBrowserSecurityContext = {
hasBrowserOriginHeader: boolean;
@@ -100,10 +110,49 @@ function isCliContainerLocalEquivalent(params: {
);
}
function resolveOriginHost(origin?: string): string {
const trimmed = origin?.trim();
if (!trimmed) {
return "";
}
try {
return new URL(trimmed).hostname;
} catch {
return "";
}
}
function isControlUiBrowserContainerLocalEquivalent(params: {
connectParams: ConnectParams;
requestHost?: string;
requestOrigin?: string;
remoteAddress?: string;
hasProxyHeaders: boolean;
hasBrowserOriginHeader: boolean;
sharedAuthOk: boolean;
authMethod: GatewayAuthResult["method"];
}): boolean {
const isControlUiBrowser =
params.connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI &&
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.WEBCHAT;
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return (
isControlUiBrowser &&
params.sharedAuthOk &&
usesSharedSecretAuth &&
!params.hasProxyHeaders &&
params.hasBrowserOriginHeader &&
isPrivateOrLoopbackAddress(params.remoteAddress) &&
isLoopbackHost(resolveHostName(params.requestHost)) &&
isLoopbackHost(resolveOriginHost(params.requestOrigin))
);
}
export function resolvePairingLocality(params: {
connectParams: ConnectParams;
isLocalClient: boolean;
requestHost?: string;
requestOrigin?: string;
remoteAddress?: string;
hasProxyHeaders: boolean;
hasBrowserOriginHeader: boolean;
@@ -113,6 +162,20 @@ export function resolvePairingLocality(params: {
if (params.isLocalClient) {
return "direct_local";
}
if (
isControlUiBrowserContainerLocalEquivalent({
connectParams: params.connectParams,
requestHost: params.requestHost,
requestOrigin: params.requestOrigin,
remoteAddress: params.remoteAddress,
hasProxyHeaders: params.hasProxyHeaders,
hasBrowserOriginHeader: params.hasBrowserOriginHeader,
sharedAuthOk: params.sharedAuthOk,
authMethod: params.authMethod,
})
) {
return "browser_container_local";
}
if (
isCliContainerLocalEquivalent({
connectParams: params.connectParams,

View File

@@ -737,6 +737,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams,
isLocalClient,
requestHost,
requestOrigin,
remoteAddress: remoteAddr,
hasProxyHeaders,
hasBrowserOriginHeader,