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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.2 | 2026.2.2 |
Affected products
1Patches
1fe81b1d7125afix(gateway): require shared auth before device bypass
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- github.com/openclaw/openclaw/commit/fe81b1d7125a014b8280da461f34efbf5f761575ghsapatchWEB
- github.com/advisories/GHSA-rv39-79c4-7459ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-rv39-79c4-7459ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28472ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-device-identity-check-bypass-in-gateway-websocket-connect-handshakeghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.2ghsaWEB
News mentions
0No linked articles in our index yet.