VYPR
Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026

OpenClaw < 2026.2.25 - Password Brute-Force via Browser-Origin WebSocket Authentication Bypass

CVE-2026-32025

Description

OpenClaw versions prior to 2026.2.25 contain an authentication hardening gap in browser-origin WebSocket clients that allows attackers to bypass origin checks and auth throttling on loopback deployments. An attacker can trick a user into opening a malicious webpage and perform password brute-force attacks against the gateway to establish an authenticated operator session and invoke control-plane methods.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.252026.2.25

Affected products

1

Patches

1
c736f11a16d6

fix(gateway): harden browser websocket auth chain

https://github.com/openclaw/openclawPeter SteinbergerFeb 26, 2026via ghsa
7 files changed · +105 7
  • CHANGELOG.md+1 0 modified
    @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
     - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
     - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
     - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
    +- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
     - Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
     - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
     - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
    
  • docs/gateway/configuration-reference.md+2 1 modified
    @@ -2145,8 +2145,9 @@ See [Plugins](/tools/plugin).
     - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
     - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
       - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
    +- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
     - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
    -- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds.
    +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
     - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
     - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
     - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
    
  • src/gateway/server.auth.test.ts+69 0 modified
    @@ -672,6 +672,17 @@ describe("gateway server auth/connect", () => {
           ws.close();
         });
     
    +    test("rejects non-local browser origins for non-control-ui clients", async () => {
    +      const ws = await openWs(port, { origin: "https://attacker.example" });
    +      const res = await connectReq(ws, {
    +        token: "secret",
    +        client: TEST_OPERATOR_CLIENT,
    +      });
    +      expect(res.ok).toBe(false);
    +      expect(res.error?.message ?? "").toContain("origin not allowed");
    +      ws.close();
    +    });
    +
         test("returns control ui hint when token is missing", async () => {
           const ws = await openWs(port, { origin: originForPort(port) });
           const res = await connectReq(ws, {
    @@ -701,6 +712,27 @@ describe("gateway server auth/connect", () => {
           );
           ws.close();
         });
    +
    +    test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => {
    +      testState.gatewayAuth = {
    +        mode: "token",
    +        token: "secret",
    +        rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true },
    +      };
    +      await withGatewayServer(async ({ port }) => {
    +        const firstWs = await openWs(port, { origin: originForPort(port) });
    +        const first = await connectReq(firstWs, { token: "wrong" });
    +        expect(first.ok).toBe(false);
    +        expect(first.error?.message ?? "").not.toContain("retry later");
    +        firstWs.close();
    +
    +        const secondWs = await openWs(port, { origin: originForPort(port) });
    +        const second = await connectReq(secondWs, { token: "wrong" });
    +        expect(second.ok).toBe(false);
    +        expect(second.error?.message ?? "").toContain("retry later");
    +        secondWs.close();
    +      });
    +    });
       });
     
       describe("explicit none auth", () => {
    @@ -1214,6 +1246,43 @@ describe("gateway server auth/connect", () => {
         restoreGatewayToken(prevToken);
       });
     
    +  test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
    +    const { listDevicePairing } = await import("../infra/device-pairing.js");
    +    const { randomUUID } = await import("node:crypto");
    +    const os = await import("node:os");
    +    const path = await import("node:path");
    +    const { server, ws, port, prevToken } = await startServerWithClient("secret");
    +    ws.close();
    +
    +    const browserWs = await openWs(port, { origin: originForPort(port) });
    +    const nonce = await readConnectChallengeNonce(browserWs);
    +    const { identity, device } = await createSignedDevice({
    +      token: "secret",
    +      scopes: ["operator.admin"],
    +      clientId: TEST_OPERATOR_CLIENT.id,
    +      clientMode: TEST_OPERATOR_CLIENT.mode,
    +      identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`),
    +      nonce,
    +    });
    +    const res = await connectReq(browserWs, {
    +      token: "secret",
    +      scopes: ["operator.admin"],
    +      client: TEST_OPERATOR_CLIENT,
    +      device,
    +    });
    +    expect(res.ok).toBe(false);
    +    expect(res.error?.message ?? "").toContain("pairing required");
    +
    +    const pairing = await listDevicePairing();
    +    const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
    +    expect(pending).toBeTruthy();
    +    expect(pending?.silent).toBe(false);
    +
    +    browserWs.close();
    +    await server.close();
    +    restoreGatewayToken(prevToken);
    +  });
    +
       test("merges remote node/operator pairing requests for the same unpaired device", async () => {
         const { mkdtemp } = await import("node:fs/promises");
         const { tmpdir } = await import("node:os");
    
  • src/gateway/server.impl.ts+7 0 modified
    @@ -316,6 +316,11 @@ export async function startGatewayServer(
       const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig
         ? createAuthRateLimiter(rateLimitConfig)
         : undefined;
    +  // Always keep a browser-origin fallback limiter for WS auth attempts.
    +  const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({
    +    ...rateLimitConfig,
    +    exemptLoopback: false,
    +  });
     
       let controlUiRootState: ControlUiRootState | undefined;
       if (controlUiRootOverride) {
    @@ -574,6 +579,7 @@ export async function startGatewayServer(
         canvasHostServerPort,
         resolvedAuth,
         rateLimiter: authRateLimiter,
    +    browserRateLimiter: browserAuthRateLimiter,
         gatewayMethods,
         events: GATEWAY_EVENTS,
         logGateway: log,
    @@ -777,6 +783,7 @@ export async function startGatewayServer(
           }
           skillsChangeUnsub();
           authRateLimiter?.dispose();
    +      browserAuthRateLimiter.dispose();
           channelHealthMonitor?.stop();
           await close(opts);
         },
    
  • src/gateway/server/ws-connection/message-handler.ts+19 6 modified
    @@ -99,6 +99,8 @@ export function attachGatewayWsMessageHandler(params: {
       resolvedAuth: ResolvedGatewayAuth;
       /** Optional rate limiter for auth brute-force protection. */
       rateLimiter?: AuthRateLimiter;
    +  /** Browser-origin fallback limiter (loopback is never exempt). */
    +  browserRateLimiter?: AuthRateLimiter;
       gatewayMethods: string[];
       events: string[];
       extraHandlers: GatewayRequestHandlers;
    @@ -130,6 +132,7 @@ export function attachGatewayWsMessageHandler(params: {
         connectNonce,
         resolvedAuth,
         rateLimiter,
    +    browserRateLimiter,
         gatewayMethods,
         events,
         extraHandlers,
    @@ -192,6 +195,12 @@ export function attachGatewayWsMessageHandler(params: {
     
       const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
       const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
    +  const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== "");
    +  const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders;
    +  const browserRateLimitClientIp =
    +    hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp;
    +  const authRateLimiter =
    +    hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter;
     
       socket.on("message", async (data) => {
         if (isClosed()) {
    @@ -329,7 +338,7 @@ export function attachGatewayWsMessageHandler(params: {
     
             const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
             const isWebchat = isWebchatConnect(connectParams);
    -        if (isControlUi || isWebchat) {
    +        if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) {
               const originCheck = checkBrowserOrigin({
                 requestHost,
                 origin: requestOrigin,
    @@ -377,8 +386,8 @@ export function attachGatewayWsMessageHandler(params: {
               req: upgradeReq,
               trustedProxies,
               allowRealIpFallback,
    -          rateLimiter,
    -          clientIp,
    +          rateLimiter: authRateLimiter,
    +          clientIp: browserRateLimitClientIp,
             });
             const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
               markHandshakeFailure("unauthorized", {
    @@ -556,8 +565,8 @@ export function attachGatewayWsMessageHandler(params: {
               deviceId: device?.id,
               role,
               scopes,
    -          rateLimiter,
    -          clientIp,
    +          rateLimiter: authRateLimiter,
    +          clientIp: browserRateLimitClientIp,
               verifyDeviceToken,
             }));
             if (!authOk) {
    @@ -613,11 +622,15 @@ export function attachGatewayWsMessageHandler(params: {
               const requirePairing = async (
                 reason: "not-paired" | "role-upgrade" | "scope-upgrade",
               ) => {
    +            const allowSilentLocalPairing =
    +              isLocalClient &&
    +              (!hasBrowserOriginHeader || isControlUi || isWebchat) &&
    +              (reason === "not-paired" || reason === "scope-upgrade");
                 const pairing = await requestDevicePairing({
                   deviceId: device.id,
                   publicKey: devicePublicKey,
                   ...clientAccessMetadata,
    -              silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
    +              silent: allowSilentLocalPairing,
                 });
                 const context = buildRequestContext();
                 if (pairing.request.silent === true) {
    
  • src/gateway/server/ws-connection.ts+4 0 modified
    @@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: {
       resolvedAuth: ResolvedGatewayAuth;
       /** Optional rate limiter for auth brute-force protection. */
       rateLimiter?: AuthRateLimiter;
    +  /** Browser-origin fallback limiter (loopback is never exempt). */
    +  browserRateLimiter?: AuthRateLimiter;
       gatewayMethods: string[];
       events: string[];
       logGateway: SubsystemLogger;
    @@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: {
         canvasHostServerPort,
         resolvedAuth,
         rateLimiter,
    +    browserRateLimiter,
         gatewayMethods,
         events,
         logGateway,
    @@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: {
           connectNonce,
           resolvedAuth,
           rateLimiter,
    +      browserRateLimiter,
           gatewayMethods,
           events,
           extraHandlers,
    
  • src/gateway/server-ws-runtime.ts+3 0 modified
    @@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: {
       resolvedAuth: ResolvedGatewayAuth;
       /** Optional rate limiter for auth brute-force protection. */
       rateLimiter?: AuthRateLimiter;
    +  /** Browser-origin fallback limiter (loopback is never exempt). */
    +  browserRateLimiter?: AuthRateLimiter;
       gatewayMethods: string[];
       events: string[];
       logGateway: ReturnType<typeof createSubsystemLogger>;
    @@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: {
         canvasHostServerPort: params.canvasHostServerPort,
         resolvedAuth: params.resolvedAuth,
         rateLimiter: params.rateLimiter,
    +    browserRateLimiter: params.browserRateLimiter,
         gatewayMethods: params.gatewayMethods,
         events: params.events,
         logGateway: params.logGateway,
    

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.