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:
assertGatewayAuthConfigured (auth.ts:285) — validates token/password/trusted-proxy preconditions but has no branch for mode === "none". Passes silently for any tailscale configuration.
- The funnel guard (
server-runtime-config.ts:125) — blocks authMode !== "password" when tailscaleMode === "funnel". Does not apply to tailscaleMode === "serve".
- 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:
- Set
gateway.auth.mode = "none" and gateway.tailscale.mode = "serve" in ~/.openclaw/openclaw.json.
- Start the gateway:
openclaw gateway run.
- From any other device on the same Tailnet, connect to the Tailscale serve URL (e.g.,
https://<hostname>.ts.net).
- 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.
Severity Assessment
CVSS Assessment
Threat Model Alignment
Classification:
security-specificThe trust boundary crossed is the gateway authentication boundary:
SECURITY.mddocumentsgateway.authas the credential gate that authenticates callers to gateway APIs, and classifies authenticated callers as trusted operators. Whengateway.auth.mode=noneis combined withgateway.tailscale.mode=serve, the startup validation inserver-runtime-config.tsallows 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 enforcingpassword, 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=noneis not adangerous*/dangerously*config key, and the exposure is not to localhost but to all Tailnet peers.Impact
When
gateway.auth.modeis set to"none"andgateway.tailscale.modeis 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" }fromauthorizeGatewayConnectwithout presenting any credential, gaining full operator-level access to the gateway control plane.Affected Component
File:
src/gateway/server-runtime-config.ts:124-137File:
src/gateway/auth.ts:285-320File:
src/gateway/auth.ts:402-404Technical Reproduction
The startup validation chain in
resolveGatewayRuntimeConfighas three relevant guards:assertGatewayAuthConfigured(auth.ts:285) — validates token/password/trusted-proxy preconditions but has no branch formode === "none". Passes silently for any tailscale configuration.server-runtime-config.ts:125) — blocksauthMode !== "password"whentailscaleMode === "funnel". Does not apply totailscaleMode === "serve".server-runtime-config.ts:133) — blocksmode: "none"on LAN/custom binds by requiringhasSharedSecret || authMode === "trusted-proxy". Does not apply here because Tailscale serve enforces loopback bind (server-runtime-config.ts:130), soisLoopbackHost(bindHost)istrue.The combination
tailscaleMode === "serve"+authMode === "none"satisfies all three guards:assertGatewayAuthConfiguredpasses silently formode: "none".resolveGatewayRuntimeConfigsucceeds, the gateway starts, andauthorizeGatewayConnectunconditionally 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,
resolveGatewayAuthatauth.ts:271-273auto-enablesallowTailscalewhentailscaleMode === "serve"andmode !== "password" && mode !== "trusted-proxy"— this setsallowTailscale: trueformode: "none"+ serve, though themode: "none"early return inauthorizeGatewayConnectfires before the Tailscale header auth path is reached.Deterministic repro config:
Steps:
gateway.auth.mode = "none"andgateway.tailscale.mode = "serve"in~/.openclaw/openclaw.json.openclaw gateway run.https://<hostname>.ts.net).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): handlestoken,password, andtrusted-proxymodes only —mode: "none"hits no branch and returns silently, regardless of Tailscale exposure.server-runtime-config.ts:125): requirespasswordfor funnel mode — does not coverservemode, leaving an asymmetric validation gap.server-runtime-config.ts:133): guards against unauthenticated non-loopback binds — cannot fire because Tailscale serve is enforced to loopback at line 130, meaningisLoopbackHost(bindHost)is alwaystruewhen this path is reached.authorizeGatewayConnect(auth.ts:402-404): unconditionally returns{ ok: true, method: "none" }formode: "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(commit23d5d24b323b98d680f2e8004c902c542e3f9642, published 2026-03-14). Affects any OpenClaw instance configured withgateway.auth.mode=noneandgateway.tailscale.mode=serve.Remediation Advice
Add a guard in
resolveGatewayRuntimeConfigthat explicitly rejectsauth.mode = "none"whentailscaleMode !== "off", matching the existing pattern for the funnel guard at line 125. For example: iftailscaleMode === "serve" && authMode === "none", throw an error requiring the operator to configure a real auth mode. Alternatively, extendassertGatewayAuthConfiguredto accepttailscaleModeas a parameter and rejectmode: "none"whenever any Tailscale exposure is active.