VYPR
Low severity3.7NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41333

CVE-2026-41333

Description

OpenClaw before 2026.3.31 contains an authentication rate limiting bypass vulnerability that allows attackers to circumvent shared authentication protections using fake device tokens. Attackers can exploit the mixed WebSocket authentication flow to bypass rate limiting controls and conduct brute force attacks against weak shared passwords.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

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

Patches

1
af0c0862f22c

fix(gateway): preserve shared-auth rate limits during mixed handshakes (#57647)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
4 files changed · +114 26
  • CHANGELOG.md+1 0 modified
    @@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
     - Matrix/delivery recovery: treat Synapse `User not in room` replay failures as permanent during startup recovery so poisoned queued messages move to `failed/` instead of crash-looping Matrix after restart. (#57426) thanks @dlardo.
     - Plugins/facades: guard bundled plugin facade loads with a cache-first sentinel so circular re-entry stops crashing `xai`, `sglang`, and `vllm` during gateway plugin startup. (#57508) Thanks @openperf.
     - Agents/MCP: dispose bundled MCP runtimes after one-shot `openclaw agent --local` runs finish, while preserving bundled MCP state across in-run retries so local JSON runs exit cleanly without restarting stateful MCP tools mid-run.
    +- Gateway/auth: keep shared-auth rate limiting active during WebSocket handshake attempts even when callers also send device-token candidates, so bogus device-token fields no longer suppress shared-secret brute-force tracking. Thanks @kexinoh and @vincentkoc.
     - Heartbeat/auth: prevent exec-event heartbeat runs from inheriting owner-only tool access from the session delivery target, so node exec output stays on the non-owner tool surface even when the target session belongs to the owner. Thanks @AntAISecurityLab and @vincentkoc.
     - Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc.
     - Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.
    
  • src/gateway/server/ws-connection/auth-context.state.test.ts+108 0 added
    @@ -0,0 +1,108 @@
    +import { describe, expect, it, vi } from "vitest";
    +import type { AuthRateLimiter } from "../../auth-rate-limit.js";
    +import type { ResolvedGatewayAuth } from "../../auth.js";
    +import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
    +
    +function createLimiter() {
    +  return {
    +    check: vi.fn(() => ({ allowed: true, retryAfterMs: 5_000 })),
    +    reset: vi.fn(),
    +    recordFailure: vi.fn(),
    +  } as unknown as AuthRateLimiter;
    +}
    +
    +describe("resolveConnectAuthState", () => {
    +  it("records shared-secret failures even when an explicit device token is also present", async () => {
    +    const rateLimiter = createLimiter();
    +    const state = await resolveConnectAuthState({
    +      resolvedAuth: {
    +        mode: "token",
    +        token: "correct-secret",
    +        allowTailscale: false,
    +      } satisfies ResolvedGatewayAuth,
    +      connectAuth: {
    +        token: "wrong-secret",
    +        deviceToken: "fake-device-token",
    +      },
    +      hasDeviceIdentity: true,
    +      req: {
    +        headers: {},
    +        socket: { remoteAddress: "203.0.113.20" },
    +      } as never,
    +      trustedProxies: [],
    +      allowRealIpFallback: false,
    +      rateLimiter,
    +      clientIp: "203.0.113.20",
    +    });
    +
    +    expect(state.authOk).toBe(false);
    +    expect(state.authResult.reason).toBe("token_mismatch");
    +    expect(
    +      (rateLimiter as never as { recordFailure: ReturnType<typeof vi.fn> }).recordFailure,
    +    ).toHaveBeenCalled();
    +  });
    +
    +  it("does not apply shared-secret lockouts to explicit device-token-only handshakes", async () => {
    +    const rateLimiter = {
    +      check: vi.fn(() => ({ allowed: false, retryAfterMs: 5_000 })),
    +      reset: vi.fn(),
    +      recordFailure: vi.fn(),
    +    } as unknown as AuthRateLimiter;
    +
    +    const state = await resolveConnectAuthState({
    +      resolvedAuth: {
    +        mode: "token",
    +        token: "correct-secret",
    +        allowTailscale: false,
    +      } satisfies ResolvedGatewayAuth,
    +      connectAuth: {
    +        deviceToken: "device-token-only",
    +      },
    +      hasDeviceIdentity: true,
    +      req: {
    +        headers: {},
    +        socket: { remoteAddress: "203.0.113.20" },
    +      } as never,
    +      trustedProxies: [],
    +      allowRealIpFallback: false,
    +      rateLimiter,
    +      clientIp: "203.0.113.20",
    +    });
    +
    +    expect(state.authOk).toBe(false);
    +    expect(state.authResult.rateLimited).not.toBe(true);
    +    expect(
    +      (rateLimiter as never as { check: ReturnType<typeof vi.fn> }).check,
    +    ).not.toHaveBeenCalled();
    +  });
    +});
    +
    +describe("resolveConnectAuthDecision", () => {
    +  it("resets the shared-secret limiter after device-token auth succeeds", async () => {
    +    const rateLimiter = createLimiter();
    +    await resolveConnectAuthDecision({
    +      state: {
    +        authResult: { ok: false, reason: "token_mismatch" },
    +        authOk: false,
    +        authMethod: "token",
    +        sharedAuthOk: false,
    +        sharedAuthProvided: true,
    +        deviceTokenCandidate: "device-token",
    +        deviceTokenCandidateSource: "explicit-device-token",
    +      },
    +      hasDeviceIdentity: true,
    +      deviceId: "dev-1",
    +      publicKey: "pub-1",
    +      role: "operator",
    +      scopes: ["operator.read"],
    +      verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
    +      verifyDeviceToken: async () => ({ ok: true }),
    +      rateLimiter,
    +      clientIp: "203.0.113.20",
    +    });
    +
    +    expect(
    +      (rateLimiter as never as { reset: ReturnType<typeof vi.fn> }).reset,
    +    ).toHaveBeenCalledWith("203.0.113.20", "shared-secret");
    +  });
    +});
    
  • src/gateway/server/ws-connection/auth-context.test.ts+1 1 modified
    @@ -114,7 +114,7 @@ describe("resolveConnectAuthDecision", () => {
         expect(decision.authOk).toBe(true);
         expect(decision.authMethod).toBe("device-token");
         expect(verifyDeviceToken).toHaveBeenCalledOnce();
    -    expect(rateLimiter.reset).toHaveBeenCalledOnce();
    +    expect(rateLimiter.reset).toHaveBeenCalledWith("203.0.113.20", "device-token");
       });
     
       it("accepts valid bootstrap tokens before device-token fallback", async () => {
    
  • src/gateway/server/ws-connection/auth-context.ts+4 25 modified
    @@ -3,7 +3,6 @@ import {
       AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
       AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
       type AuthRateLimiter,
    -  type RateLimitCheckResult,
     } from "../../auth-rate-limit.js";
     import {
       authorizeHttpGatewayConnect,
    @@ -98,41 +97,18 @@ export async function resolveConnectAuthState(params: {
         : undefined;
       const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
         params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
    -  const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
     
       let authResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({
         auth: params.resolvedAuth,
         connectAuth: sharedConnectAuth,
         req: params.req,
         trustedProxies: params.trustedProxies,
         allowRealIpFallback: params.allowRealIpFallback,
    -    rateLimiter: hasDeviceTokenCandidate ? undefined : params.rateLimiter,
    +    rateLimiter: sharedAuthProvided ? params.rateLimiter : undefined,
         clientIp: params.clientIp,
         rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
       });
     
    -  if (
    -    hasDeviceTokenCandidate &&
    -    authResult.ok &&
    -    params.rateLimiter &&
    -    (authResult.method === "token" || authResult.method === "password")
    -  ) {
    -    const sharedRateCheck: RateLimitCheckResult = params.rateLimiter.check(
    -      params.clientIp,
    -      AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
    -    );
    -    if (!sharedRateCheck.allowed) {
    -      authResult = {
    -        ok: false,
    -        reason: "rate_limited",
    -        rateLimited: true,
    -        retryAfterMs: sharedRateCheck.retryAfterMs,
    -      };
    -    } else {
    -      params.rateLimiter.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
    -    }
    -  }
    -
       const sharedAuthResult =
         sharedConnectAuth &&
         (await authorizeHttpGatewayConnect({
    @@ -246,6 +222,9 @@ export async function resolveConnectAuthDecision(params: {
           authOk = true;
           authMethod = "device-token";
           params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
    +      if (params.state.sharedAuthProvided) {
    +        params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
    +      }
         } else {
           authResult = {
             ok: false,
    

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

5

News mentions

0

No linked articles in our index yet.