VYPR
Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026

OpenClaw < 2026.2.21 - Authentication Bypass in HTTP Gateway Routes via Tokenless Tailscale Auth

CVE-2026-32045

Description

OpenClaw versions prior to 2026.2.21 incorrectly apply tokenless Tailscale header authentication to HTTP gateway routes, allowing bypass of token and password requirements. Attackers on trusted networks can exploit this misconfiguration to access HTTP gateway routes without proper authentication credentials.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.212026.2.21

Affected products

1

Patches

1
356d61aacfa5

fix(gateway): scope tailscale tokenless auth to websocket

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
16 files changed · +134 15
  • docs/gateway/configuration-reference.md+1 1 modified
    @@ -2060,7 +2060,7 @@ See [Plugins](/tools/plugin).
     - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
     - `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
     - `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
    -- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
    +- `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).
     - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
    
  • docs/gateway/remote.md+4 3 modified
    @@ -122,9 +122,10 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
     - **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
     - `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
     - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
    -- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
    -  This tokenless flow assumes the gateway host is trusted. Set it to `false` if you
    -  want tokens/passwords instead.
    +- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
    +  headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still
    +  require token/password auth. This tokenless flow assumes the gateway host is
    +  trusted. Set it to `false` if you want tokens/passwords everywhere.
     - Treat browser control like operator access: tailnet-only + deliberate node pairing.
     
     Deep dive: [Security](/gateway/security).
    
  • docs/gateway/security/index.md+4 2 modified
    @@ -532,12 +532,14 @@ Rotation checklist (token/password):
     ### 0.6) Tailscale Serve identity headers
     
     When `gateway.auth.allowTailscale` is `true` (default for Serve), OpenClaw
    -accepts Tailscale Serve identity headers (`tailscale-user-login`) as
    -authentication. OpenClaw verifies the identity by resolving the
    +accepts Tailscale Serve identity headers (`tailscale-user-login`) for Control
    +UI/WebSocket authentication. OpenClaw verifies the identity by resolving the
     `x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
     and matching it to the header. This only triggers for requests that hit loopback
     and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
     injected by Tailscale.
    +HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`)
    +still require token/password auth.
     
     **Trust assumption:** tokenless Serve auth assumes the gateway host is trusted.
     Do not treat this as protection against hostile same-host processes. If untrusted
    
  • docs/gateway/tailscale.md+3 1 modified
    @@ -26,13 +26,15 @@ Set `gateway.auth.mode` to control the handshake:
     - `password` (shared secret via `OPENCLAW_GATEWAY_PASSWORD` or config)
     
     When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
    -valid Serve proxy requests can authenticate via Tailscale identity headers
    +Control UI/WebSocket auth can use Tailscale identity headers
     (`tailscale-user-login`) without supplying a token/password. OpenClaw verifies
     the identity by resolving the `x-forwarded-for` address via the local Tailscale
     daemon (`tailscale whois`) and matching it to the header before accepting it.
     OpenClaw only treats a request as Serve when it arrives from loopback with
     Tailscale’s `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
     headers.
    +HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`)
    +still require token/password auth.
     This tokenless flow assumes the gateway host is trusted. If untrusted local code
     may run on the same host, disable `gateway.auth.allowTailscale` and require
     token/password auth instead.
    
  • docs/help/faq.md+1 1 modified
    @@ -348,7 +348,7 @@ The wizard opens your browser with a clean (non-tokenized) dashboard URL right a
     
     **Not on localhost:**
     
    -- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token, assumes trusted gateway host).
    +- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy Control UI/WebSocket auth (no token, assumes trusted gateway host); HTTP APIs still require token/password.
     - **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
     - **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/` and paste the token in Control UI settings.
     
    
  • docs/platforms/digitalocean.md+1 1 modified
    @@ -132,7 +132,7 @@ Open: `https://<magicdns>/`
     
     Notes:
     
    -- Serve keeps the Gateway loopback-only and authenticates via Tailscale identity headers (tokenless auth assumes trusted gateway host).
    +- Serve keeps the Gateway loopback-only and authenticates Control UI/WebSocket traffic via Tailscale identity headers (tokenless auth assumes trusted gateway host; HTTP APIs still require token/password).
     - To require token/password instead, set `gateway.auth.allowTailscale: false` or use `gateway.auth.mode: "password"`.
     
     **Option C: Tailnet bind (no Serve)**
    
  • docs/web/control-ui.md+1 1 modified
    @@ -117,7 +117,7 @@ Open:
     
     - `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
     
    -By default, Serve requests can authenticate via Tailscale identity headers
    +By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers
     (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw
     verifies the identity by resolving the `x-forwarded-for` address with
     `tailscale whois` and matching it to the header, and only accepts these when the
    
  • docs/web/dashboard.md+1 1 modified
    @@ -37,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
     
     - **Localhost**: open `http://127.0.0.1:18789/`.
     - **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
    -- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`, assumes trusted gateway host), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
    +- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
     
     ## If you see “unauthorized” / 1008
     
    
  • docs/web/index.md+3 2 modified
    @@ -101,8 +101,9 @@ Open:
     - The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
     - The Control UI sends anti-clickjacking headers and only accepts same-origin browser
       websocket connections unless `gateway.controlUi.allowedOrigins` is set.
    -- With Serve, Tailscale identity headers can satisfy auth when
    -  `gateway.auth.allowTailscale` is `true` (no token/password required). Set
    +- With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth
    +  when `gateway.auth.allowTailscale` is `true` (no token/password required).
    +  HTTP API endpoints still require token/password. Set
       `gateway.auth.allowTailscale: false` to require explicit credentials. See
       [Tailscale](/gateway/tailscale) and [Security](/gateway/security). This
       tokenless flow assumes the gateway host is trusted.
    
  • src/gateway/auth.test.ts+24 1 modified
    @@ -188,7 +188,7 @@ describe("gateway auth", () => {
         expect(res.method).toBe("token");
       });
     
    -  it("allows tailscale identity to satisfy token mode auth", async () => {
    +  it("does not allow tailscale identity to satisfy token mode auth by default", async () => {
         const res = await authorizeGatewayConnect({
           auth: { mode: "token", token: "secret", allowTailscale: true },
           connectAuth: null,
    @@ -206,6 +206,29 @@ describe("gateway auth", () => {
           } as never,
         });
     
    +    expect(res.ok).toBe(false);
    +    expect(res.reason).toBe("token_missing");
    +  });
    +
    +  it("allows tailscale identity when header auth is explicitly enabled", async () => {
    +    const res = await authorizeGatewayConnect({
    +      auth: { mode: "token", token: "secret", allowTailscale: true },
    +      connectAuth: null,
    +      tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
    +      allowTailscaleHeaderAuth: true,
    +      req: {
    +        socket: { remoteAddress: "127.0.0.1" },
    +        headers: {
    +          host: "gateway.local",
    +          "x-forwarded-for": "100.64.0.1",
    +          "x-forwarded-proto": "https",
    +          "x-forwarded-host": "ai-hub.bone-egret.ts.net",
    +          "tailscale-user-login": "peter",
    +          "tailscale-user-name": "Peter",
    +        },
    +      } as never,
    +    });
    +
         expect(res.ok).toBe(true);
         expect(res.method).toBe("tailscale");
         expect(res.user).toBe("peter");
    
  • src/gateway/auth.ts+7 1 modified
    @@ -325,6 +325,11 @@ export async function authorizeGatewayConnect(params: {
       req?: IncomingMessage;
       trustedProxies?: string[];
       tailscaleWhois?: TailscaleWhoisLookup;
    +  /**
    +   * Opt-in for accepting Tailscale Serve identity headers as primary auth.
    +   * Default is disabled for HTTP surfaces; WS connect enables this explicitly.
    +   */
    +  allowTailscaleHeaderAuth?: boolean;
       /** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */
       rateLimiter?: AuthRateLimiter;
       /** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */
    @@ -334,6 +339,7 @@ export async function authorizeGatewayConnect(params: {
     }): Promise<GatewayAuthResult> {
       const { auth, connectAuth, req, trustedProxies } = params;
       const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
    +  const allowTailscaleHeaderAuth = params.allowTailscaleHeaderAuth === true;
       const localDirect = isLocalDirectRequest(req, trustedProxies);
     
       if (auth.mode === "trusted-proxy") {
    @@ -376,7 +382,7 @@ export async function authorizeGatewayConnect(params: {
         }
       }
     
    -  if (auth.allowTailscale && !localDirect) {
    +  if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) {
         const tailscaleCheck = await resolveVerifiedTailscaleUser({
           req,
           tailscaleWhois,
    
  • src/gateway/http-auth-helpers.test.ts+79 0 added
    @@ -0,0 +1,79 @@
    +import type { IncomingMessage, ServerResponse } from "node:http";
    +import { beforeEach, describe, expect, it, vi } from "vitest";
    +import type { ResolvedGatewayAuth } from "./auth.js";
    +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
    +
    +vi.mock("./auth.js", () => ({
    +  authorizeGatewayConnect: vi.fn(),
    +}));
    +
    +vi.mock("./http-common.js", () => ({
    +  sendGatewayAuthFailure: vi.fn(),
    +}));
    +
    +vi.mock("./http-utils.js", () => ({
    +  getBearerToken: vi.fn(),
    +}));
    +
    +const { authorizeGatewayConnect } = await import("./auth.js");
    +const { sendGatewayAuthFailure } = await import("./http-common.js");
    +const { getBearerToken } = await import("./http-utils.js");
    +
    +describe("authorizeGatewayBearerRequestOrReply", () => {
    +  beforeEach(() => {
    +    vi.clearAllMocks();
    +  });
    +
    +  it("disables tailscale header auth for HTTP bearer checks", async () => {
    +    vi.mocked(getBearerToken).mockReturnValue(null);
    +    vi.mocked(authorizeGatewayConnect).mockResolvedValue({
    +      ok: false,
    +      reason: "token_missing",
    +    });
    +
    +    const ok = await authorizeGatewayBearerRequestOrReply({
    +      req: {} as IncomingMessage,
    +      res: {} as ServerResponse,
    +      auth: {
    +        mode: "token",
    +        token: "secret",
    +        password: undefined,
    +        allowTailscale: true,
    +      } satisfies ResolvedGatewayAuth,
    +    });
    +
    +    expect(ok).toBe(false);
    +    expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        allowTailscaleHeaderAuth: false,
    +        connectAuth: null,
    +      }),
    +    );
    +    expect(vi.mocked(sendGatewayAuthFailure)).toHaveBeenCalledTimes(1);
    +  });
    +
    +  it("forwards bearer token and returns true on successful auth", async () => {
    +    vi.mocked(getBearerToken).mockReturnValue("abc");
    +    vi.mocked(authorizeGatewayConnect).mockResolvedValue({ ok: true, method: "token" });
    +
    +    const ok = await authorizeGatewayBearerRequestOrReply({
    +      req: {} as IncomingMessage,
    +      res: {} as ServerResponse,
    +      auth: {
    +        mode: "token",
    +        token: "secret",
    +        password: undefined,
    +        allowTailscale: true,
    +      } satisfies ResolvedGatewayAuth,
    +    });
    +
    +    expect(ok).toBe(true);
    +    expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        allowTailscaleHeaderAuth: false,
    +        connectAuth: { token: "abc", password: "abc" },
    +      }),
    +    );
    +    expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled();
    +  });
    +});
    
  • src/gateway/http-auth-helpers.ts+1 0 modified
    @@ -17,6 +17,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
         connectAuth: token ? { token, password: token } : null,
         req: params.req,
         trustedProxies: params.trustedProxies,
    +    allowTailscaleHeaderAuth: false,
         rateLimiter: params.rateLimiter,
       });
       if (!authResult.ok) {
    
  • src/gateway/server-http.ts+2 0 modified
    @@ -155,6 +155,7 @@ async function authorizeCanvasRequest(params: {
           connectAuth: { token, password: token },
           req,
           trustedProxies,
    +      allowTailscaleHeaderAuth: false,
           rateLimiter,
         });
         if (authResult.ok) {
    @@ -532,6 +533,7 @@ export function createGatewayHttpServer(opts: {
                 connectAuth: token ? { token, password: token } : null,
                 req,
                 trustedProxies,
    +            allowTailscaleHeaderAuth: false,
                 rateLimiter,
               });
               if (!authResult.ok) {
    
  • src/gateway/server/ws-connection/message-handler.ts+1 0 modified
    @@ -351,6 +351,7 @@ export function attachGatewayWsMessageHandler(params: {
               connectAuth: connectParams.auth,
               req: upgradeReq,
               trustedProxies,
    +          allowTailscaleHeaderAuth: true,
               rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter,
               clientIp,
               rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
    
  • src/gateway/tools-invoke-http.ts+1 0 modified
    @@ -151,6 +151,7 @@ export async function handleToolsInvokeHttpRequest(
         connectAuth: token ? { token, password: token } : null,
         req,
         trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
    +    allowTailscaleHeaderAuth: false,
         rateLimiter: opts.rateLimiter,
       });
       if (!authResult.ok) {
    

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.