Skip to content

[Bug]: Tailscale serve + auth.mode=none exposes gateway to full Tailnet without authentication #50630

@coygeek

Description

@coygeek

Severity Assessment

CVSS Assessment

Metric v3.1 v4.0
Score 9.3 / 10.0 9.3 / 10.0
Severity Critical Critical
Vector CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N
Calculator CVSS v3.1 Calculator CVSS v4.0 Calculator

Threat Model Alignment

Classification: security-specific

The trust boundary crossed is the gateway authentication boundary: SECURITY.md documents gateway.auth as the credential gate that authenticates callers to gateway APIs, and classifies authenticated callers as trusted operators. When gateway.auth.mode=none is combined with gateway.tailscale.mode=serve, the startup validation in server-runtime-config.ts allows the gateway to start without any authentication credential while simultaneously being exposed to all peers on the user's Tailnet via Tailscale's reverse proxy. The funnel mode has an equivalent guard enforcing password, but the serve-mode path has no parallel check, leaving a startup-validation gap that is not covered by any Out of Scope clause — gateway.auth.mode=none is not a dangerous*/dangerously* config key, and the exposure is not to localhost but to all Tailnet peers.

Impact

When gateway.auth.mode is set to "none" and gateway.tailscale.mode is set to "serve", the gateway is reachable over the user's entire Tailnet with zero authentication. Any Tailnet peer connecting through the Tailscale serve URL receives { ok: true, method: "none" } from authorizeGatewayConnect without presenting any credential, gaining full operator-level access to the gateway control plane.

Affected Component

File: src/gateway/server-runtime-config.ts:124-137

assertGatewayAuthConfigured(resolvedAuth, params.cfg.gateway?.auth); // line 124 — no branch for mode:"none"
if (tailscaleMode === "funnel" && authMode !== "password") {          // line 125
  throw new Error(
    "tailscale funnel requires gateway auth mode=password ...",
  );
}
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {          // line 130
  throw new Error("tailscale serve/funnel requires gateway bind=loopback ...");
}
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") { // line 133
  throw new Error(
    `refusing to bind gateway to ${bindHost}:${params.port} without auth ...`,
  );
}

File: src/gateway/auth.ts:285-320

export function assertGatewayAuthConfigured(
  auth: ResolvedGatewayAuth,
  rawAuthConfig?: GatewayAuthConfig | null,
): void {
  if (auth.mode === "token" && !auth.token) { ... throw ... }
  if (auth.mode === "password" && !auth.password) { ... throw ... }
  if (auth.mode === "trusted-proxy") { ... throw ... }
  // No branch for mode === "none" — passes silently regardless of tailscale config
}

File: src/gateway/auth.ts:402-404

if (auth.mode === "none") {
  return { ok: true, method: "none" };
}

Technical Reproduction

The startup validation chain in resolveGatewayRuntimeConfig has three relevant guards:

  1. assertGatewayAuthConfigured (auth.ts:285) — validates token/password/trusted-proxy preconditions but has no branch for mode === "none". Passes silently for any tailscale configuration.
  2. The funnel guard (server-runtime-config.ts:125) — blocks authMode !== "password" when tailscaleMode === "funnel". Does not apply to tailscaleMode === "serve".
  3. The non-loopback LAN guard (server-runtime-config.ts:133) — blocks mode: "none" on LAN/custom binds by requiring hasSharedSecret || authMode === "trusted-proxy". Does not apply here because Tailscale serve enforces loopback bind (server-runtime-config.ts:130), so isLoopbackHost(bindHost) is true.

The combination tailscaleMode === "serve" + authMode === "none" satisfies all three guards:

  • Guard at line 130 passes because serve mode enforces loopback — which then exempts the connection from the non-loopback guard at line 133.
  • The funnel-only guard at line 125 does not apply to serve.
  • assertGatewayAuthConfigured passes silently for mode: "none".

resolveGatewayRuntimeConfig succeeds, the gateway starts, and authorizeGatewayConnect unconditionally returns { ok: true, method: "none" } (auth.ts:402-404) for every incoming connection. Tailscale's local proxy relays requests from any Tailnet peer to the loopback gateway port, bypassing the auth check entirely.

Additionally, resolveGatewayAuth at auth.ts:271-273 auto-enables allowTailscale when tailscaleMode === "serve" and mode !== "password" && mode !== "trusted-proxy" — this sets allowTailscale: true for mode: "none" + serve, though the mode: "none" early return in authorizeGatewayConnect fires before the Tailscale header auth path is reached.

Deterministic repro config:

gateway:
  auth:
    mode: none
  tailscale:
    mode: serve

Steps:

  1. Set gateway.auth.mode = "none" and gateway.tailscale.mode = "serve" in ~/.openclaw/openclaw.json.
  2. Start the gateway: openclaw gateway run.
  3. From any other device on the same Tailnet, connect to the Tailscale serve URL (e.g., https://<hostname>.ts.net).
  4. Observe: connection is accepted with authMethod: "none" — no token, password, or device pairing required.

Demonstrated Impact

All four guards that could block this configuration fail to do so:

  • assertGatewayAuthConfigured (auth.ts:285): handles token, password, and trusted-proxy modes only — mode: "none" hits no branch and returns silently, regardless of Tailscale exposure.
  • Funnel guard (server-runtime-config.ts:125): requires password for funnel mode — does not cover serve mode, leaving an asymmetric validation gap.
  • Non-loopback guard (server-runtime-config.ts:133): guards against unauthenticated non-loopback binds — cannot fire because Tailscale serve is enforced to loopback at line 130, meaning isLoopbackHost(bindHost) is always true when this path is reached.
  • authorizeGatewayConnect (auth.ts:402-404): unconditionally returns { ok: true, method: "none" } for mode: "none" with no consideration of whether Tailscale is active.

The result is operator-level access granted to every Tailnet peer without any credential. An attacker sharing the tailnet — or an attacker who has compromised any tailnet device — can call all gateway HTTP API endpoints (/v1/chat/completions, /v1/responses, /tools/invoke, /api/channels/*) and the WebSocket control plane without authentication.

Environment

Verified against release v2026.3.13-1 (commit 23d5d24b323b98d680f2e8004c902c542e3f9642, published 2026-03-14). Affects any OpenClaw instance configured with gateway.auth.mode=none and gateway.tailscale.mode=serve.

Remediation Advice

Add a guard in resolveGatewayRuntimeConfig that explicitly rejects auth.mode = "none" when tailscaleMode !== "off", matching the existing pattern for the funnel guard at line 125. For example: if tailscaleMode === "serve" && authMode === "none", throw an error requiring the operator to configure a real auth mode. Alternatively, extend assertGatewayAuthConfigured to accept tailscaleMode as a parameter and reject mode: "none" whenever any Tailscale exposure is active.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions