From 7616b02bb10632769064a44af4142aa008b693e3 Mon Sep 17 00:00:00 2001 From: Roshan Singh Date: Tue, 13 Jan 2026 03:55:04 +0000 Subject: [PATCH 1/2] Fix tailscale allowTailscale bypass in token mode --- src/gateway/auth.test.ts | 22 +++++++++++++++++++++ src/gateway/auth.ts | 41 +++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index ad7c91c0e..a6d2e145e 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -92,4 +92,26 @@ describe("gateway auth", () => { expect(missingProxy.ok).toBe(false); expect(missingProxy.reason).toBe("tailscale_proxy_missing"); }); + + it("allows tailscale identity to satisfy token mode auth", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: true }, + connectAuth: null, + 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 91577a342..8d7702f9a 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -146,21 +146,29 @@ export async function authorizeGatewayConnect(params: { const { auth, connectAuth, req } = params; const localDirect = isLocalDirectRequest(req); - if (auth.mode === "none") { - if (auth.allowTailscale && !localDirect) { - const tailscaleUser = getTailscaleUser(req); - if (!tailscaleUser) { - return { ok: false, reason: "tailscale_user_missing" }; - } - if (!isTailscaleProxyRequest(req)) { - return { ok: false, reason: "tailscale_proxy_missing" }; - } + if (auth.allowTailscale && !localDirect) { + const tailscaleUser = getTailscaleUser(req); + const tailscaleProxy = isTailscaleProxyRequest(req); + + if (tailscaleUser && tailscaleProxy) { return { ok: true, method: "tailscale", user: tailscaleUser.login, }; } + + if (auth.mode === "none") { + if (!tailscaleUser) { + return { ok: false, reason: "tailscale_user_missing" }; + } + if (!tailscaleProxy) { + return { ok: false, reason: "tailscale_proxy_missing" }; + } + } + } + + if (auth.mode === "none") { return { ok: true, method: "none" }; } @@ -191,20 +199,5 @@ export async function authorizeGatewayConnect(params: { return { ok: true, method: "password" }; } - if (auth.allowTailscale) { - const tailscaleUser = getTailscaleUser(req); - if (!tailscaleUser) { - return { ok: false, reason: "tailscale_user_missing" }; - } - if (!isTailscaleProxyRequest(req)) { - return { ok: false, reason: "tailscale_proxy_missing" }; - } - return { - ok: true, - method: "tailscale", - user: tailscaleUser.login, - }; - } - return { ok: false, reason: "unauthorized" }; } From b70298fbca2f8eab59061d6286b44806bbf256db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 04:37:04 +0000 Subject: [PATCH 2/2] fix: document Tailscale Serve auth headers (#823) (thanks @roshanasingh4) --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 7 ++++++- docs/gateway/security.md | 14 ++++++++++++++ docs/gateway/tailscale.md | 10 +++++++--- docs/web/control-ui.md | 8 ++++++-- docs/web/index.md | 5 ++++- 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0684cbff..47f2f17b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow) - Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm) - Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4) +- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4) - Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake) - Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs. - Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2582a737d..25fe5db92 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2191,7 +2191,12 @@ Auth and Tailscale: - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). -- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. +- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers + (`tailscale-user-login`) to satisfy auth when the request arrives on loopback + with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When + `true`, Serve requests do not need a token/password; set `false` to require + explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and + auth mode is not `password`. - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 84e380741..3110df279 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -145,6 +145,20 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`. Note: `gateway.remote.token` is **only** for remote CLI calls; it does not protect local WS access. +### 0.6) Tailscale Serve identity headers + +When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot +accepts Tailscale Serve identity headers (`tailscale-user-login`) as +authentication. This only triggers for requests that hit loopback and include +`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by +Tailscale. + +**Security rule:** do not forward these headers from your own reverse proxy. If +you terminate TLS or proxy in front of the gateway, disable +`gateway.auth.allowTailscale` and use token/password auth instead. + +See [Tailscale](/gateway/tailscale) and [Web overview](/web). + ### 1) DMs: pairing by default ```json5 diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index d7f4f8ff0..586c80dc0 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -23,9 +23,13 @@ Set `gateway.auth.mode` to control the handshake: - `token` (default when `CLAWDBOT_GATEWAY_TOKEN` is set) - `password` (shared secret via `CLAWDBOT_GATEWAY_PASSWORD` or config) -When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by -default unless you force `gateway.auth.mode` to `password` or set -`gateway.auth.allowTailscale: false`. +When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`, +valid Serve proxy requests can authenticate via Tailscale identity headers +(`tailscale-user-login`) without supplying a token/password. Clawdbot 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. +To require explicit credentials, set `gateway.auth.allowTailscale: false` or +force `gateway.auth.mode: "password"`. ## Config examples diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index fa2124081..758d58f4e 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -77,8 +77,12 @@ clawdbot gateway --tailscale serve Open: - `https:///` (or your configured `gateway.controlUi.basePath`) -By default, the gateway trusts Tailscale identity headers in serve mode. You can still set -`gateway.auth` (or `CLAWDBOT_GATEWAY_TOKEN`) if you want a shared secret instead. +By default, Serve requests can authenticate via Tailscale identity headers +(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot +only accepts these when the request hits loopback with Tailscale’s +`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force +`gateway.auth.mode: "password"`) if you want to require a token/password even +for Serve traffic. ### Bind to tailnet + token diff --git a/docs/web/index.md b/docs/web/index.md index daf9bfd91..82ca62205 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -94,7 +94,10 @@ Open: - Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`). - The wizard generates a gateway token by default (even on loopback). - The UI sends `connect.params.auth.token` or `connect.params.auth.password`. -- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode. +- With Serve, Tailscale identity headers can satisfy auth when + `gateway.auth.allowTailscale` is `true` (no token/password required). Set + `gateway.auth.allowTailscale: false` to require explicit credentials. See + [Tailscale](/gateway/tailscale) and [Security](/gateway/security). - `gateway.tailscale.mode: "funnel"` requires `gateway.auth.mode: "password"` (shared password). ## Building the UI