From 356d61aacfa5b0f1d5830716ec59d70682a3e7b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 13:03:08 +0100 Subject: [PATCH] fix(gateway): scope tailscale tokenless auth to websocket --- docs/gateway/configuration-reference.md | 2 +- docs/gateway/remote.md | 7 +- docs/gateway/security/index.md | 6 +- docs/gateway/tailscale.md | 4 +- docs/help/faq.md | 2 +- docs/platforms/digitalocean.md | 2 +- docs/web/control-ui.md | 2 +- docs/web/dashboard.md | 2 +- docs/web/index.md | 5 +- src/gateway/auth.test.ts | 25 +++++- src/gateway/auth.ts | 8 +- src/gateway/http-auth-helpers.test.ts | 79 +++++++++++++++++++ src/gateway/http-auth-helpers.ts | 1 + src/gateway/server-http.ts | 2 + .../server/ws-connection/message-handler.ts | 1 + src/gateway/tools-invoke-http.ts | 1 + 16 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 src/gateway/http-auth-helpers.test.ts diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 699d557e460..c3f8cb72fab 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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). diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index f95ecd90ab0..6eedfc3b35d 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -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). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 517cbaa6821..467cdb371a9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index 8a195d6d357..6bc5187698c 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -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. diff --git a/docs/help/faq.md b/docs/help/faq.md index 6958fec3213..5b19415165b 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -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:///`. 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:///`. 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 ""`, open `http://: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. diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index 4cd60877001..bddc63b9d1f 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -132,7 +132,7 @@ Open: `https:///` 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)** diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 4a3decb7e90..9ff05572ca0 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -117,7 +117,7 @@ Open: - `https:///` (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 diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index b4432a5983c..0aed38b2c8b 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -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 diff --git a/docs/web/index.md b/docs/web/index.md index 6d1b40990ce..42baffe8027 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -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. diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index acc761ea881..6cdc645f7f8 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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"); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index f3a7f2d9056..76236dc2e0a 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -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 { 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, diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts new file mode 100644 index 00000000000..0a4cab7d4e5 --- /dev/null +++ b/src/gateway/http-auth-helpers.test.ts @@ -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(); + }); +}); diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index 449e9369c95..a7ee69eb6fb 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -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) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 63185280c74..6cabb0fb4d3 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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) { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 40dcc54e6b7..61be61fafdd 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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, diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index bd2c78a6dc8..eda5b816429 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -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) {