VYPR
Critical severityNVD Advisory· Published Mar 20, 2026· Updated Mar 20, 2026

OpenClaw < 2026.3.12 - Scope Elevation in WebSocket Shared-Auth Connections

CVE-2026-22172

Description

OpenClaw versions prior to 2026.3.12 contain an authorization bypass vulnerability in the WebSocket connect path that allows shared-token or password-authenticated connections to self-declare elevated scopes without server-side binding. Attackers can exploit this logic flaw to present unauthorized scopes such as operator.admin and perform admin-only gateway operations.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.122026.3.12

Affected products

1

Patches

1
5e389d5e7c92

Gateway/ws: clear unbound scopes for shared-token auth (#44306)

https://github.com/openclaw/openclawVincent KocMar 12, 2026via ghsa
4 files changed · +51 4
  • CHANGELOG.md+1 0 modified
    @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
     - Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
     - Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.
     - Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
    +- Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (`GHSA-rqpp-rjj8-7wv8`)(#44306) Thanks @LUOYEcode and @vincentkoc.
     - Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc.
     - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc.
     - Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc.
    
  • src/gateway/server.auth.compat-baseline.test.ts+37 0 modified
    @@ -6,6 +6,7 @@ import {
       getFreePort,
       openWs,
       originForPort,
    +  rpcReq,
       restoreGatewayToken,
       startGatewayServer,
       testState,
    @@ -62,6 +63,24 @@ describe("gateway auth compatibility baseline", () => {
           }
         });
     
    +    test("clears client-declared scopes for shared-token operator connects", async () => {
    +      const ws = await openWs(port);
    +      try {
    +        const res = await connectReq(ws, {
    +          token: "secret",
    +          scopes: ["operator.admin"],
    +          device: null,
    +        });
    +        expect(res.ok).toBe(true);
    +
    +        const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
    +        expect(adminRes.ok).toBe(false);
    +        expect(adminRes.error?.message).toBe("missing scope: operator.admin");
    +      } finally {
    +        ws.close();
    +      }
    +    });
    +
         test("returns stable token-missing details for control ui without token", async () => {
           const ws = await openWs(port, { origin: originForPort(port) });
           try {
    @@ -163,6 +182,24 @@ describe("gateway auth compatibility baseline", () => {
             ws.close();
           }
         });
    +
    +    test("clears client-declared scopes for shared-password operator connects", async () => {
    +      const ws = await openWs(port);
    +      try {
    +        const res = await connectReq(ws, {
    +          password: "secret",
    +          scopes: ["operator.admin"],
    +          device: null,
    +        });
    +        expect(res.ok).toBe(true);
    +
    +        const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
    +        expect(adminRes.ok).toBe(false);
    +        expect(adminRes.error?.message).toBe("missing scope: operator.admin");
    +      } finally {
    +        ws.close();
    +      }
    +    });
       });
     
       describe("none mode", () => {
    
  • src/gateway/server.auth.control-ui.suite.ts+9 0 modified
    @@ -91,6 +91,11 @@ export function registerControlUiAndPairingSuite(): void {
         expect(health.ok).toBe(true);
       };
     
    +  const expectAdminRpcOk = async (ws: WebSocket) => {
    +    const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
    +    expect(admin.ok).toBe(true);
    +  };
    +
       const connectControlUiWithoutDeviceAndExpectOk = async (params: {
         ws: WebSocket;
         token?: string;
    @@ -104,6 +109,7 @@ export function registerControlUiAndPairingSuite(): void {
         });
         expect(res.ok).toBe(true);
         await expectStatusAndHealthOk(params.ws);
    +    await expectAdminRpcOk(params.ws);
       };
     
       const createOperatorIdentityFixture = async (identityPrefix: string) => {
    @@ -217,6 +223,9 @@ export function registerControlUiAndPairingSuite(): void {
             }
             if (tc.expectStatusChecks) {
               await expectStatusAndHealthOk(ws);
    +          if (tc.role === "operator") {
    +            await expectAdminRpcOk(ws);
    +          }
             }
             ws.close();
           });
    
  • src/gateway/server/ws-connection/message-handler.ts+4 4 modified
    @@ -643,15 +643,12 @@ export function attachGatewayWsMessageHandler(params: {
               close(1008, truncateCloseReason(authMessage));
             };
             const clearUnboundScopes = () => {
    -          if (scopes.length > 0 && !controlUiAuthPolicy.allowBypass && !sharedAuthOk) {
    +          if (scopes.length > 0) {
                 scopes = [];
                 connectParams.scopes = scopes;
               }
             };
             const handleMissingDeviceIdentity = (): boolean => {
    -          if (!device) {
    -            clearUnboundScopes();
    -          }
               const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
                 isControlUi,
                 role,
    @@ -670,6 +667,9 @@ export function attachGatewayWsMessageHandler(params: {
                 hasSharedAuth,
                 isLocalClient,
               });
    +          if (!device && (!isControlUi || decision.kind !== "allow")) {
    +            clearUnboundScopes();
    +          }
               if (decision.kind === "allow") {
                 return true;
               }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.