Root cause
Authentication & access control introduced
fix(gateway): allow trusted-proxy control-ui auth to skip device pairing
How AI contributed: Direct introductionPublished Mar 3, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary A trusted-proxy Control UI pairing bypass accepted client.id=control-ui without device identity checks. The bypass did not require operator role, so an authenticated node role session could connect unpaired and reach node event methods. Impact With trusted-proxy authentication enabled, a node role websocket client could skip pairing by using client.id=control-ui. That created an authorization boundary bypass from a node-scoped connection into node event execution flows. Affected Packages / Versions · Package: openclaw (npm) · Affected range: <= 2026.2.24 · Latest published vulnerable version: 2026.2.24 · Patched in next release: 2026.2.25 (pre-set below so this advisory is ready to publish after npm release) Fix The trusted-proxy Control UI bypass now additionally requires role === "operator". Fix Commit(s) · ec45c317f5d0631a3d333b236da58c4749ede2a3 Release Process Note patched_versions is intentionally pre-set to the release (2026.2.25). Advisory published with npm release 2026.2.25.2.25` is published, the remaining GHSA action is to publish this advisory. OpenClaw thanks @tdjackey for reporting.
trustedProxyAuthOk allows Control UI pairing without an operator role gate; invariant: trusted-proxy Control UI skip must require role===operator.
Root cause
fix(gateway): allow trusted-proxy control-ui auth to skip device pairing
Fix
fix(gateway): block trusted-proxy control-ui node bypass
Fix by Peter Steinberger · no AI marker found
Case facts
@@ -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 => {@@ -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@@ -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);@@ -491,6 +491,7 @@ export function attachGatewayWsMessageHandler(params: { } const trustedProxyAuthOk = isControlUi &&+ role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy";@@ -629,6 +630,7 @@ export function attachGatewayWsMessageHandler(params: { const trustedProxyAuthOk = isControlUi &&+ role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy";@@ -131,10 +131,11 @@ async function expectHelloOkServerVersion(port: number, expectedVersion: string) } async function createSignedDevice(params: {- token: string;+ token?: string | null; scopes: string[]; clientId: string; clientMode: string;+ role?: "operator" | "node"; identityPath?: string; nonce: string; signedAtMs?: number;@@ -149,10 +150,10 @@ async function createSignedDevice(params: { deviceId: identity.deviceId, clientId: params.clientId, clientMode: params.clientMode,- role: "operator",+ role: params.role ?? "operator", scopes: params.scopes, signedAtMs,- token: params.token,+ token: params.token ?? null, nonce: params.nonce, }); return {@@ -187,6 +188,23 @@ async function approvePendingPairingIfNeeded() { } } +async function configureTrustedProxyControlUiAuth() {+ testState.gatewayAuth = {+ mode: "trusted-proxy",+ trustedProxy: {+ userHeader: "x-forwarded-user",+ requiredHeaders: ["x-forwarded-proto"],+ },+ };+ const { writeConfigFile } = await import("../config/config.js");+ await writeConfigFile({+ gateway: {+ trustedProxies: ["127.0.0.1"],+ },+ // oxlint-disable-next-line typescript/no-explicit-any+ } as any);+}+ function isConnectResMessage(id: string) { return (o: unknown) => { if (!o || typeof o !== "object" || Array.isArray(o)) {@@ -776,6 +794,90 @@ 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": "[email protected]",+ });+ 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();+ });+ });++ 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": "[email protected]",+ });+ const res = await connectReq(ws, {+ skipDefaultAuth: true,AI-assisted change dfd13af480075fe069f06513ffcaf7afa8b2be550e318f03c5291230ed43cf05 · Fix ecfa7469c0daf723f11ce72521198cd42fe86e5748f8eb4c9d70d3e093f2e22e