VYPR
High severity8.8NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026

CVE-2026-35663

CVE-2026-35663

Description

OpenClaw before 2026.3.25 contains a privilege escalation vulnerability allowing non-admin operators to self-request broader scopes during backend reconnect. Attackers can bypass pairing requirements to reconnect as operator.admin, gaining unauthorized administrative privileges.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
<= 2026.3.24

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.25

Patches

1
d3d8e316bd81

gateway: require pairing for backend scope upgrades (#55286)

https://github.com/openclaw/openclawJacob TomlinsonMar 26, 2026via ghsa
4 files changed · +68 81
  • src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts+62 0 modified
    @@ -84,6 +84,68 @@ describe("gateway silent scope-upgrade reconnect", () => {
         }
       });
     
    +  test("does not let backend reconnect bypass the paired scope baseline", async () => {
    +    const started = await startServerWithClient("secret");
    +    const paired = await issueOperatorToken({
    +      name: "backend-scope-upgrade-reconnect-poc",
    +      approvedScopes: ["operator.read"],
    +      clientId: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
    +      clientMode: GATEWAY_CLIENT_MODES.BACKEND,
    +    });
    +
    +    let watcherWs: WebSocket | undefined;
    +    let backendReconnectWs: WebSocket | undefined;
    +
    +    try {
    +      watcherWs = await openTrackedWs(started.port);
    +      await connectOk(watcherWs, { scopes: ["operator.admin"] });
    +      const requestedEvent = onceMessage(
    +        watcherWs,
    +        (obj) => obj.type === "event" && obj.event === "device.pair.requested",
    +      );
    +
    +      backendReconnectWs = await openTrackedWs(started.port);
    +      const reconnectAttempt = await connectReq(backendReconnectWs, {
    +        token: "secret",
    +        deviceIdentityPath: paired.identityPath,
    +        client: {
    +          id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
    +          version: "1.0.0",
    +          platform: "node",
    +          mode: GATEWAY_CLIENT_MODES.BACKEND,
    +        },
    +        role: "operator",
    +        scopes: ["operator.admin"],
    +      });
    +      expect(reconnectAttempt.ok).toBe(false);
    +      expect(reconnectAttempt.error?.message).toBe("pairing required");
    +
    +      const pending = await devicePairingModule.listDevicePairing();
    +      expect(pending.pending).toHaveLength(1);
    +      expect(
    +        (reconnectAttempt.error?.details as { requestId?: unknown; code?: string })?.requestId,
    +      ).toBe(pending.pending[0]?.requestId);
    +
    +      const requested = (await requestedEvent) as {
    +        payload?: { requestId?: string; deviceId?: string; scopes?: string[] };
    +      };
    +      expect(requested.payload?.requestId).toBe(pending.pending[0]?.requestId);
    +      expect(requested.payload?.deviceId).toBe(paired.deviceId);
    +      expect(requested.payload?.scopes).toEqual(["operator.admin"]);
    +
    +      const afterAttempt = await getPairedDevice(paired.deviceId);
    +      expect(afterAttempt?.approvedScopes).toEqual(["operator.read"]);
    +      expect(afterAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]);
    +      expect(afterAttempt?.tokens?.operator?.token).toBe(paired.token);
    +    } finally {
    +      watcherWs?.close();
    +      backendReconnectWs?.close();
    +      started.ws.close();
    +      await started.server.close();
    +      started.envSnapshot.restore();
    +    }
    +  });
    +
       test("accepts local silent reconnect when pairing was concurrently approved", async () => {
         const started = await startServerWithClient("secret");
         const loaded = loadDeviceIdentity("silent-reconnect-race");
    
  • src/gateway/server/ws-connection/handshake-auth-helpers.test.ts+0 40 modified
    @@ -1,13 +1,10 @@
     import { describe, expect, it } from "vitest";
     import type { AuthRateLimiter } from "../../auth-rate-limit.js";
    -import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
    -import type { ConnectParams } from "../../protocol/index.js";
     import {
       BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
       resolveHandshakeBrowserSecurityContext,
       resolveUnauthorizedHandshakeContext,
       shouldAllowSilentLocalPairing,
    -  shouldSkipBackendSelfPairing,
     } from "./handshake-auth-helpers.js";
     
     function createRateLimiter(): AuthRateLimiter {
    @@ -88,41 +85,4 @@ describe("handshake auth helpers", () => {
           }),
         ).toBe(false);
       });
    -
    -  it("skips backend self-pairing for local trusted backend clients", () => {
    -    const connectParams = {
    -      client: {
    -        id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
    -        mode: GATEWAY_CLIENT_MODES.BACKEND,
    -      },
    -    } as ConnectParams;
    -
    -    expect(
    -      shouldSkipBackendSelfPairing({
    -        connectParams,
    -        isLocalClient: true,
    -        hasBrowserOriginHeader: false,
    -        sharedAuthOk: true,
    -        authMethod: "token",
    -      }),
    -    ).toBe(true);
    -    expect(
    -      shouldSkipBackendSelfPairing({
    -        connectParams,
    -        isLocalClient: true,
    -        hasBrowserOriginHeader: false,
    -        sharedAuthOk: false,
    -        authMethod: "device-token",
    -      }),
    -    ).toBe(true);
    -    expect(
    -      shouldSkipBackendSelfPairing({
    -        connectParams,
    -        isLocalClient: false,
    -        hasBrowserOriginHeader: false,
    -        sharedAuthOk: true,
    -        authMethod: "token",
    -      }),
    -    ).toBe(false);
    -  });
     });
    
  • src/gateway/server/ws-connection/handshake-auth-helpers.ts+0 26 modified
    @@ -3,7 +3,6 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
     import type { GatewayAuthResult } from "../../auth.js";
     import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
     import { isLoopbackAddress } from "../../net.js";
    -import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
     import type { ConnectParams } from "../../protocol/index.js";
     import type { AuthProvidedKind } from "./auth-messages.js";
     
    @@ -60,31 +59,6 @@ export function shouldAllowSilentLocalPairing(params: {
       );
     }
     
    -export function shouldSkipBackendSelfPairing(params: {
    -  connectParams: ConnectParams;
    -  isLocalClient: boolean;
    -  hasBrowserOriginHeader: boolean;
    -  sharedAuthOk: boolean;
    -  authMethod: GatewayAuthResult["method"];
    -}): boolean {
    -  const isGatewayBackendClient =
    -    params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
    -    params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
    -  if (!isGatewayBackendClient) {
    -    return false;
    -  }
    -  const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
    -  const usesDeviceTokenAuth = params.authMethod === "device-token";
    -  // `authMethod === "device-token"` only reaches this helper after the caller
    -  // has already accepted auth (`authOk === true`), so a separate
    -  // `deviceTokenAuthOk` flag would be redundant here.
    -  return (
    -    params.isLocalClient &&
    -    !params.hasBrowserOriginHeader &&
    -    ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth)
    -  );
    -}
    -
     function resolveSignatureToken(connectParams: ConnectParams): string | null {
       return (
         connectParams.auth?.token ??
    
  • src/gateway/server/ws-connection/message-handler.ts+6 15 modified
    @@ -91,7 +91,6 @@ import {
       resolveHandshakeBrowserSecurityContext,
       resolveUnauthorizedHandshakeContext,
       shouldAllowSilentLocalPairing,
    -  shouldSkipBackendSelfPairing,
     } from "./handshake-auth-helpers.js";
     import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
     
    @@ -686,20 +685,12 @@ export function attachGatewayWsMessageHandler(params: {
               authOk,
               authMethod,
             });
    -        const skipPairing =
    -          shouldSkipBackendSelfPairing({
    -            connectParams,
    -            isLocalClient,
    -            hasBrowserOriginHeader,
    -            sharedAuthOk,
    -            authMethod,
    -          }) ||
    -          shouldSkipControlUiPairing(
    -            controlUiAuthPolicy,
    -            role,
    -            trustedProxyAuthOk,
    -            resolvedAuth.mode,
    -          );
    +        const skipPairing = shouldSkipControlUiPairing(
    +          controlUiAuthPolicy,
    +          role,
    +          trustedProxyAuthOk,
    +          resolvedAuth.mode,
    +        );
             if (device && devicePublicKey && !skipPairing) {
               const formatAuditList = (items: string[] | undefined): string => {
                 if (!items || items.length === 0) {
    

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

4

News mentions

0

No linked articles in our index yet.