fix(gateway): allow trusted-proxy control-ui auth to skip device pairing

Control UI connections authenticated via gateway.auth.mode=trusted-proxy were
still forced through device pairing because pairing bypass only considered
shared token/password auth (sharedAuthOk). In trusted-proxy deployments,
this produced persistent "pairing required" failures despite valid trusted
proxy headers.

Treat authenticated trusted-proxy control-ui connections as pairing-bypass
eligible and allow missing device identity in that mode.

Fixes #25293

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SidQin-cyber
2026-02-24 21:15:57 +08:00
committed by Peter Steinberger
parent d84659f22f
commit 20523b918a
3 changed files with 47 additions and 5 deletions

View File

@@ -49,6 +49,7 @@ describe("ws connect policy", () => {
role: "node", role: "node",
isControlUi: false, isControlUi: false,
controlUiAuthPolicy: policy, controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
@@ -68,6 +69,7 @@ describe("ws connect policy", () => {
role: "operator", role: "operator",
isControlUi: true, isControlUi: true,
controlUiAuthPolicy: controlUiStrict, controlUiAuthPolicy: controlUiStrict,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
@@ -82,6 +84,7 @@ describe("ws connect policy", () => {
role: "operator", role: "operator",
isControlUi: true, isControlUi: true,
controlUiAuthPolicy: controlUiStrict, controlUiAuthPolicy: controlUiStrict,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
@@ -101,6 +104,7 @@ describe("ws connect policy", () => {
role: "operator", role: "operator",
isControlUi: true, isControlUi: true,
controlUiAuthPolicy: controlUiNoInsecure, controlUiAuthPolicy: controlUiNoInsecure,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
@@ -114,6 +118,7 @@ describe("ws connect policy", () => {
role: "operator", role: "operator",
isControlUi: false, isControlUi: false,
controlUiAuthPolicy: policy, controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
@@ -127,6 +132,7 @@ describe("ws connect policy", () => {
role: "operator", role: "operator",
isControlUi: false, isControlUi: false,
controlUiAuthPolicy: policy, controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: false, sharedAuthOk: false,
authOk: false, authOk: false,
hasSharedAuth: true, hasSharedAuth: true,
@@ -140,15 +146,31 @@ describe("ws connect policy", () => {
role: "node", role: "node",
isControlUi: false, isControlUi: false,
controlUiAuthPolicy: policy, controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true, sharedAuthOk: true,
authOk: true, authOk: true,
hasSharedAuth: true, hasSharedAuth: true,
isLocalClient: false, isLocalClient: false,
}).kind, }).kind,
).toBe("reject-device-required"); ).toBe("reject-device-required");
// Trusted-proxy authenticated Control UI should bypass device-identity gating.
expect(
evaluateMissingDeviceIdentity({
hasDeviceIdentity: false,
role: "operator",
isControlUi: true,
controlUiAuthPolicy: controlUiNoInsecure,
trustedProxyAuthOk: true,
sharedAuthOk: false,
authOk: true,
hasSharedAuth: false,
isLocalClient: false,
}).kind,
).toBe("allow");
}); });
test("pairing bypass requires control-ui bypass + shared auth", () => { test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => {
const bypass = resolveControlUiAuthPolicy({ const bypass = resolveControlUiAuthPolicy({
isControlUi: true, isControlUi: true,
controlUiConfig: { dangerouslyDisableDeviceAuth: true }, controlUiConfig: { dangerouslyDisableDeviceAuth: true },
@@ -159,8 +181,9 @@ describe("ws connect policy", () => {
controlUiConfig: undefined, controlUiConfig: undefined,
deviceRaw: null, deviceRaw: null,
}); });
expect(shouldSkipControlUiPairing(bypass, true)).toBe(true); expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, false)).toBe(false); expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, true)).toBe(false); expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true);
}); });
}); });

View File

@@ -35,7 +35,11 @@ export function resolveControlUiAuthPolicy(params: {
export function shouldSkipControlUiPairing( export function shouldSkipControlUiPairing(
policy: ControlUiAuthPolicy, policy: ControlUiAuthPolicy,
sharedAuthOk: boolean, sharedAuthOk: boolean,
trustedProxyAuthOk = false,
): boolean { ): boolean {
if (trustedProxyAuthOk) {
return true;
}
return policy.allowBypass && sharedAuthOk; return policy.allowBypass && sharedAuthOk;
} }
@@ -50,6 +54,7 @@ export function evaluateMissingDeviceIdentity(params: {
role: GatewayRole; role: GatewayRole;
isControlUi: boolean; isControlUi: boolean;
controlUiAuthPolicy: ControlUiAuthPolicy; controlUiAuthPolicy: ControlUiAuthPolicy;
trustedProxyAuthOk?: boolean;
sharedAuthOk: boolean; sharedAuthOk: boolean;
authOk: boolean; authOk: boolean;
hasSharedAuth: boolean; hasSharedAuth: boolean;
@@ -58,6 +63,9 @@ export function evaluateMissingDeviceIdentity(params: {
if (params.hasDeviceIdentity) { if (params.hasDeviceIdentity) {
return { kind: "allow" }; return { kind: "allow" };
} }
if (params.isControlUi && params.trustedProxyAuthOk) {
return { kind: "allow" };
}
if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) {
// Allow localhost Control UI connections when allowInsecureAuth is configured. // Allow localhost Control UI connections when allowInsecureAuth is configured.
// Localhost has no network interception risk, and browser SubtleCrypto // Localhost has no network interception risk, and browser SubtleCrypto

View File

@@ -427,11 +427,17 @@ export function attachGatewayWsMessageHandler(params: {
if (!device) { if (!device) {
clearUnboundScopes(); clearUnboundScopes();
} }
const trustedProxyAuthOk =
isControlUi &&
resolvedAuth.mode === "trusted-proxy" &&
authOk &&
authMethod === "trusted-proxy";
const decision = evaluateMissingDeviceIdentity({ const decision = evaluateMissingDeviceIdentity({
hasDeviceIdentity: Boolean(device), hasDeviceIdentity: Boolean(device),
role, role,
isControlUi, isControlUi,
controlUiAuthPolicy, controlUiAuthPolicy,
trustedProxyAuthOk,
sharedAuthOk, sharedAuthOk,
authOk, authOk,
hasSharedAuth, hasSharedAuth,
@@ -563,8 +569,13 @@ export function attachGatewayWsMessageHandler(params: {
// In that case, don't force device pairing on first connect. // In that case, don't force device pairing on first connect.
const skipPairingForOperatorSharedAuth = const skipPairingForOperatorSharedAuth =
role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; role === "operator" && sharedAuthOk && !isControlUi && !isWebchat;
const trustedProxyAuthOk =
isControlUi &&
resolvedAuth.mode === "trusted-proxy" &&
authOk &&
authMethod === "trusted-proxy";
const skipPairing = const skipPairing =
shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) || shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) ||
skipPairingForOperatorSharedAuth; skipPairingForOperatorSharedAuth;
if (device && devicePublicKey && !skipPairing) { if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => { const formatAuditList = (items: string[] | undefined): string => {