refactor(gateway): centralize trusted-proxy control-ui bypass policy

This commit is contained in:
Peter Steinberger
2026-02-26 02:26:46 +01:00
parent 95c6b3a912
commit 0cc3e8137c
4 changed files with 173 additions and 92 deletions

View File

@@ -105,6 +105,13 @@ const CONTROL_UI_CLIENT = {
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
};
const TRUSTED_PROXY_CONTROL_UI_HEADERS = {
origin: "https://localhost",
"x-forwarded-for": "203.0.113.10",
"x-forwarded-proto": "https",
"x-forwarded-user": "peter@example.com",
} as const;
const NODE_CLIENT = {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
@@ -794,89 +801,92 @@ describe("gateway server auth/connect", () => {
});
});
test("allows trusted-proxy control ui operator without device identity", async () => {
await configureTrustedProxyControlUiAuth();
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, {
origin: "https://localhost",
"x-forwarded-for": "203.0.113.10",
"x-forwarded-proto": "https",
"x-forwarded-user": "peter@example.com",
});
const res = await connectReq(ws, {
skipDefaultAuth: true,
role: "operator",
device: null,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(true);
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(false);
expect(status.error?.message ?? "").toContain("missing scope");
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
ws.close();
});
});
const trustedProxyControlUiCases: Array<{
name: string;
role: "operator" | "node";
withUnpairedNodeDevice: boolean;
expectedOk: boolean;
expectedErrorSubstring?: string;
expectedErrorCode?: string;
expectStatusChecks: boolean;
}> = [
{
name: "allows trusted-proxy control ui operator without device identity",
role: "operator",
withUnpairedNodeDevice: false,
expectedOk: true,
expectStatusChecks: true,
},
{
name: "rejects trusted-proxy control ui node role without device identity",
role: "node",
withUnpairedNodeDevice: false,
expectedOk: false,
expectedErrorSubstring: "control ui requires device identity",
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
expectStatusChecks: false,
},
{
name: "requires pairing for trusted-proxy control ui node role with unpaired device",
role: "node",
withUnpairedNodeDevice: true,
expectedOk: false,
expectedErrorSubstring: "pairing required",
expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
expectStatusChecks: false,
},
];
test("rejects trusted-proxy control ui node role without device identity", async () => {
await configureTrustedProxyControlUiAuth();
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, {
origin: "https://localhost",
"x-forwarded-for": "203.0.113.10",
"x-forwarded-proto": "https",
"x-forwarded-user": "peter@example.com",
for (const tc of trustedProxyControlUiCases) {
test(tc.name, async () => {
await configureTrustedProxyControlUiAuth();
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
if (tc.withUnpairedNodeDevice) {
const challengeNonce = await readConnectChallengeNonce(ws);
expect(challengeNonce).toBeTruthy();
({ device } = await createSignedDevice({
token: null,
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
nonce: String(challengeNonce),
}));
}
const res = await connectReq(ws, {
skipDefaultAuth: true,
role: tc.role,
scopes,
device,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(tc.expectedOk);
if (!tc.expectedOk) {
if (tc.expectedErrorSubstring) {
expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring);
}
if (tc.expectedErrorCode) {
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
tc.expectedErrorCode,
);
}
ws.close();
return;
}
if (tc.expectStatusChecks) {
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(false);
expect(status.error?.message ?? "").toContain("missing scope");
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
}
ws.close();
});
const res = await connectReq(ws, {
skipDefaultAuth: true,
role: "node",
device: null,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("control ui requires device identity");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
);
ws.close();
});
});
test("requires pairing for trusted-proxy control ui node role with unpaired device", async () => {
await configureTrustedProxyControlUiAuth();
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, {
origin: "https://localhost",
"x-forwarded-for": "203.0.113.10",
"x-forwarded-proto": "https",
"x-forwarded-user": "peter@example.com",
});
const challengeNonce = await readConnectChallengeNonce(ws);
expect(challengeNonce).toBeTruthy();
const { device } = await createSignedDevice({
token: null,
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
nonce: String(challengeNonce),
});
const res = await connectReq(ws, {
skipDefaultAuth: true,
role: "node",
scopes: [],
device,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
ws.close();
});
});
}
test("allows localhost control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
evaluateMissingDeviceIdentity,
isTrustedProxyControlUiOperatorAuth,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
@@ -186,4 +187,55 @@ describe("ws connect policy", () => {
expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true);
});
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
const cases: Array<{
role: "operator" | "node";
authMode: string;
authOk: boolean;
authMethod: string | undefined;
expected: boolean;
}> = [
{
role: "operator",
authMode: "trusted-proxy",
authOk: true,
authMethod: "trusted-proxy",
expected: true,
},
{
role: "node",
authMode: "trusted-proxy",
authOk: true,
authMethod: "trusted-proxy",
expected: false,
},
{
role: "operator",
authMode: "token",
authOk: true,
authMethod: "token",
expected: false,
},
{
role: "operator",
authMode: "trusted-proxy",
authOk: false,
authMethod: "trusted-proxy",
expected: false,
},
];
for (const tc of cases) {
expect(
isTrustedProxyControlUiOperatorAuth({
isControlUi: true,
role: tc.role,
authMode: tc.authMode,
authOk: tc.authOk,
authMethod: tc.authMethod,
}),
).toBe(tc.expected);
}
});
});

View File

@@ -43,6 +43,22 @@ export function shouldSkipControlUiPairing(
return policy.allowBypass && sharedAuthOk;
}
export function isTrustedProxyControlUiOperatorAuth(params: {
isControlUi: boolean;
role: GatewayRole;
authMode: string;
authOk: boolean;
authMethod: string | undefined;
}): boolean {
return (
params.isControlUi &&
params.role === "operator" &&
params.authMode === "trusted-proxy" &&
params.authOk &&
params.authMethod === "trusted-proxy"
);
}
export type MissingDeviceIdentityDecision =
| { kind: "allow" }
| { kind: "reject-control-ui-insecure-auth" }

View File

@@ -75,6 +75,7 @@ import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-cont
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
isTrustedProxyControlUiOperatorAuth,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
@@ -489,12 +490,13 @@ export function attachGatewayWsMessageHandler(params: {
if (!device) {
clearUnboundScopes();
}
const trustedProxyAuthOk =
isControlUi &&
role === "operator" &&
resolvedAuth.mode === "trusted-proxy" &&
authOk &&
authMethod === "trusted-proxy";
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
authMode: resolvedAuth.mode,
authOk,
authMethod,
});
const decision = evaluateMissingDeviceIdentity({
hasDeviceIdentity: Boolean(device),
role,
@@ -628,12 +630,13 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
const trustedProxyAuthOk =
isControlUi &&
role === "operator" &&
resolvedAuth.mode === "trusted-proxy" &&
authOk &&
authMethod === "trusted-proxy";
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
authMode: resolvedAuth.mode,
authOk,
authMethod,
});
const skipPairing = shouldSkipControlUiPairing(
controlUiAuthPolicy,
sharedAuthOk,