Merge pull request #823 from roshanasingh4/fix/820-tailscale-allow-token

Fix allowTailscale bypass for token auth in Serve mode
This commit is contained in:
Peter Steinberger 2026-01-13 04:39:55 +00:00 committed by GitHub
commit 8a9096cd52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 77 additions and 31 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -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 Tailscales
`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

View File

@ -77,8 +77,12 @@ clawdbot gateway --tailscale serve
Open:
- `https://<magicdns>/` (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 Tailscales
`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

View File

@ -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

View File

@ -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");
});
});

View File

@ -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" };
}