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",
isControlUi: false,
controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
@@ -68,6 +69,7 @@ describe("ws connect policy", () => {
role: "operator",
isControlUi: true,
controlUiAuthPolicy: controlUiStrict,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
@@ -82,6 +84,7 @@ describe("ws connect policy", () => {
role: "operator",
isControlUi: true,
controlUiAuthPolicy: controlUiStrict,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
@@ -101,6 +104,7 @@ describe("ws connect policy", () => {
role: "operator",
isControlUi: true,
controlUiAuthPolicy: controlUiNoInsecure,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
@@ -114,6 +118,7 @@ describe("ws connect policy", () => {
role: "operator",
isControlUi: false,
controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
@@ -127,6 +132,7 @@ describe("ws connect policy", () => {
role: "operator",
isControlUi: false,
controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: false,
authOk: false,
hasSharedAuth: true,
@@ -140,15 +146,31 @@ describe("ws connect policy", () => {
role: "node",
isControlUi: false,
controlUiAuthPolicy: policy,
trustedProxyAuthOk: false,
sharedAuthOk: true,
authOk: true,
hasSharedAuth: true,
isLocalClient: false,
}).kind,
).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({
isControlUi: true,
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
@@ -159,8 +181,9 @@ describe("ws connect policy", () => {
controlUiConfig: undefined,
deviceRaw: null,
});
expect(shouldSkipControlUiPairing(bypass, true)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, true)).toBe(false);
expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, false, false)).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(
policy: ControlUiAuthPolicy,
sharedAuthOk: boolean,
trustedProxyAuthOk = false,
): boolean {
if (trustedProxyAuthOk) {
return true;
}
return policy.allowBypass && sharedAuthOk;
}
@@ -50,6 +54,7 @@ export function evaluateMissingDeviceIdentity(params: {
role: GatewayRole;
isControlUi: boolean;
controlUiAuthPolicy: ControlUiAuthPolicy;
trustedProxyAuthOk?: boolean;
sharedAuthOk: boolean;
authOk: boolean;
hasSharedAuth: boolean;
@@ -58,6 +63,9 @@ export function evaluateMissingDeviceIdentity(params: {
if (params.hasDeviceIdentity) {
return { kind: "allow" };
}
if (params.isControlUi && params.trustedProxyAuthOk) {
return { kind: "allow" };
}
if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) {
// Allow localhost Control UI connections when allowInsecureAuth is configured.
// Localhost has no network interception risk, and browser SubtleCrypto

View File

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