VYPR
Critical severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw < 2026.2.2 - Device Identity Check Bypass in Gateway WebSocket Connect Handshake

CVE-2026-28472

Description

OpenClaw versions prior to 2026.2.2 contain a vulnerability in the gateway WebSocket connect handshake in which it allows skipping device identity checks when auth.token is present but not validated. Attackers can connect to the gateway without providing device identity or pairing by exploiting the presence check instead of validation, potentially gaining operator access in vulnerable deployments.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.22026.2.2

Affected products

1

Patches

1
fe81b1d7125a

fix(gateway): require shared auth before device bypass

https://github.com/openclaw/openclawPeter SteinbergerFeb 3, 2026via ghsa
5 files changed · +131 44
  • CHANGELOG.md+1 0 modified
    @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
     - Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
     - Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
     - Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
    +- Security: require validated shared-secret auth before skipping device identity on gateway connect.
     - Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
     - Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
     - Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.
    
  • src/gateway/server.auth.e2e.test.ts+53 0 modified
    @@ -11,6 +11,7 @@ import {
       onceMessage,
       startGatewayServer,
       startServerWithClient,
    +  testTailscaleWhois,
       testState,
     } from "./test-helpers.js";
     
    @@ -35,6 +36,20 @@ const openWs = async (port: number) => {
       return ws;
     };
     
    +const openTailscaleWs = async (port: number) => {
    +  const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
    +    headers: {
    +      "x-forwarded-for": "100.64.0.1",
    +      "x-forwarded-proto": "https",
    +      "x-forwarded-host": "gateway.tailnet.ts.net",
    +      "tailscale-user-login": "peter",
    +      "tailscale-user-name": "Peter",
    +    },
    +  });
    +  await new Promise<void>((resolve) => ws.once("open", resolve));
    +  return ws;
    +};
    +
     describe("gateway server auth/connect", () => {
       describe("default auth (token)", () => {
         let server: Awaited<ReturnType<typeof startGatewayServer>>;
    @@ -279,6 +294,44 @@ describe("gateway server auth/connect", () => {
         });
       });
     
    +  describe("tailscale auth", () => {
    +    let server: Awaited<ReturnType<typeof startGatewayServer>>;
    +    let port: number;
    +
    +    beforeAll(async () => {
    +      testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
    +      port = await getFreePort();
    +      server = await startGatewayServer(port);
    +    });
    +
    +    afterAll(async () => {
    +      await server.close();
    +    });
    +
    +    beforeEach(() => {
    +      testTailscaleWhois.value = { login: "peter", name: "Peter" };
    +    });
    +
    +    afterEach(() => {
    +      testTailscaleWhois.value = null;
    +    });
    +
    +    test("requires device identity when only tailscale auth is available", async () => {
    +      const ws = await openTailscaleWs(port);
    +      const res = await connectReq(ws, { token: "dummy", device: null });
    +      expect(res.ok).toBe(false);
    +      expect(res.error?.message ?? "").toContain("device identity required");
    +      ws.close();
    +    });
    +
    +    test("allows shared token to skip device when tailscale auth is enabled", async () => {
    +      const ws = await openTailscaleWs(port);
    +      const res = await connectReq(ws, { token: "secret", device: null });
    +      expect(res.ok).toBe(true);
    +      ws.close();
    +    });
    +  });
    +
       test("allows control ui without device identity when insecure auth is enabled", async () => {
         testState.gatewayControlUi = { allowInsecureAuth: true };
         const { server, ws, prevToken } = await startServerWithClient("secret");
    
  • src/gateway/server/ws-connection/message-handler.ts+63 44 modified
    @@ -377,8 +377,63 @@ export function attachGatewayWsMessageHandler(params: {
               isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
             const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
             const device = disableControlUiDeviceAuth ? null : deviceRaw;
    +
    +        const authResult = await authorizeGatewayConnect({
    +          auth: resolvedAuth,
    +          connectAuth: connectParams.auth,
    +          req: upgradeReq,
    +          trustedProxies,
    +        });
    +        let authOk = authResult.ok;
    +        let authMethod =
    +          authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
    +        const sharedAuthResult = hasSharedAuth
    +          ? await authorizeGatewayConnect({
    +              auth: { ...resolvedAuth, allowTailscale: false },
    +              connectAuth: connectParams.auth,
    +              req: upgradeReq,
    +              trustedProxies,
    +            })
    +          : null;
    +        const sharedAuthOk =
    +          sharedAuthResult?.ok === true &&
    +          (sharedAuthResult.method === "token" || sharedAuthResult.method === "password");
    +        const rejectUnauthorized = () => {
    +          setHandshakeState("failed");
    +          logWsControl.warn(
    +            `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
    +          );
    +          const authProvided: AuthProvidedKind = connectParams.auth?.token
    +            ? "token"
    +            : connectParams.auth?.password
    +              ? "password"
    +              : "none";
    +          const authMessage = formatGatewayAuthFailureMessage({
    +            authMode: resolvedAuth.mode,
    +            authProvided,
    +            reason: authResult.reason,
    +            client: connectParams.client,
    +          });
    +          setCloseCause("unauthorized", {
    +            authMode: resolvedAuth.mode,
    +            authProvided,
    +            authReason: authResult.reason,
    +            allowTailscale: resolvedAuth.allowTailscale,
    +            client: connectParams.client.id,
    +            clientDisplayName: connectParams.client.displayName,
    +            mode: connectParams.client.mode,
    +            version: connectParams.client.version,
    +          });
    +          send({
    +            type: "res",
    +            id: frame.id,
    +            ok: false,
    +            error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
    +          });
    +          close(1008, truncateCloseReason(authMessage));
    +        };
             if (!device) {
    -          const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
    +          const canSkipDevice = sharedAuthOk;
     
               if (isControlUi && !allowControlUiBypass) {
                 const errorMessage = "control ui requires HTTPS or localhost (secure context)";
    @@ -399,8 +454,12 @@ export function attachGatewayWsMessageHandler(params: {
                 return;
               }
     
    -          // Allow token-authenticated connections (e.g., control-ui) to skip device identity
    +          // Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity
               if (!canSkipDevice) {
    +            if (!authOk && hasSharedAuth) {
    +              rejectUnauthorized();
    +              return;
    +            }
                 setHandshakeState("failed");
                 setCloseCause("device-required", {
                   client: connectParams.client.id,
    @@ -567,15 +626,6 @@ export function attachGatewayWsMessageHandler(params: {
               }
             }
     
    -        const authResult = await authorizeGatewayConnect({
    -          auth: resolvedAuth,
    -          connectAuth: connectParams.auth,
    -          req: upgradeReq,
    -          trustedProxies,
    -        });
    -        let authOk = authResult.ok;
    -        let authMethod =
    -          authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
             if (!authOk && connectParams.auth?.token && device) {
               const tokenCheck = await verifyDeviceToken({
                 deviceId: device.id,
    @@ -589,42 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
               }
             }
             if (!authOk) {
    -          setHandshakeState("failed");
    -          logWsControl.warn(
    -            `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
    -          );
    -          const authProvided: AuthProvidedKind = connectParams.auth?.token
    -            ? "token"
    -            : connectParams.auth?.password
    -              ? "password"
    -              : "none";
    -          const authMessage = formatGatewayAuthFailureMessage({
    -            authMode: resolvedAuth.mode,
    -            authProvided,
    -            reason: authResult.reason,
    -            client: connectParams.client,
    -          });
    -          setCloseCause("unauthorized", {
    -            authMode: resolvedAuth.mode,
    -            authProvided,
    -            authReason: authResult.reason,
    -            allowTailscale: resolvedAuth.allowTailscale,
    -            client: connectParams.client.id,
    -            clientDisplayName: connectParams.client.displayName,
    -            mode: connectParams.client.mode,
    -            version: connectParams.client.version,
    -          });
    -          send({
    -            type: "res",
    -            id: frame.id,
    -            ok: false,
    -            error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
    -          });
    -          close(1008, truncateCloseReason(authMessage));
    +          rejectUnauthorized();
               return;
             }
     
    -        const skipPairing = allowControlUiBypass && hasSharedAuth;
    +        const skipPairing = allowControlUiBypass && sharedAuthOk;
             if (device && devicePublicKey && !skipPairing) {
               const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
                 const pairing = await requestDevicePairing({
    
  • src/gateway/test-helpers.mocks.ts+12 0 modified
    @@ -7,6 +7,7 @@ import { Mock, vi } from "vitest";
     import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
     import type { AgentBinding } from "../config/types.agents.js";
     import type { HooksConfig } from "../config/types.hooks.js";
    +import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
     import type { PluginRegistry } from "../plugins/registry.js";
     import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
     import { setActivePluginRegistry } from "../plugins/runtime.js";
    @@ -167,6 +168,7 @@ const hoisted = vi.hoisted(() => ({
         waitCalls: [] as string[],
         waitResults: new Map<string, boolean>(),
       },
    +  testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null },
       getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
       sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
     }));
    @@ -196,6 +198,7 @@ export const setTestConfigRoot = (root: string) => {
     };
     
     export const testTailnetIPv4 = hoisted.testTailnetIPv4;
    +export const testTailscaleWhois = hoisted.testTailscaleWhois;
     export const piSdkMock = hoisted.piSdkMock;
     export const cronIsolatedRun = hoisted.cronIsolatedRun;
     export const agentCommand: Mock<() => void> = hoisted.agentCommand;
    @@ -258,6 +261,15 @@ vi.mock("../infra/tailnet.js", () => ({
       pickPrimaryTailnetIPv6: () => undefined,
     }));
     
    +vi.mock("../infra/tailscale.js", async () => {
    +  const actual =
    +    await vi.importActual<typeof import("../infra/tailscale.js")>("../infra/tailscale.js");
    +  return {
    +    ...actual,
    +    readTailscaleWhoisIdentity: async () => testTailscaleWhois.value,
    +  };
    +});
    +
     vi.mock("../config/sessions.js", async () => {
       const actual =
         await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
    
  • src/gateway/test-helpers.server.ts+2 0 modified
    @@ -28,6 +28,7 @@ import {
       sessionStoreSaveDelayMs,
       setTestConfigRoot,
       testIsNixMode,
    +  testTailscaleWhois,
       testState,
       testTailnetIPv4,
     } from "./test-helpers.mocks.js";
    @@ -109,6 +110,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
       setTestConfigRoot(tempConfigRoot);
       sessionStoreSaveDelayMs.value = 0;
       testTailnetIPv4.value = undefined;
    +  testTailscaleWhois.value = null;
       testState.gatewayBind = undefined;
       testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
       testState.gatewayControlUi = undefined;
    

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.