From fd9be79be1940cba1d472427a2be6c6907f36fa9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 12:47:53 +0000 Subject: [PATCH] fix: harden tailscale serve auth --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 9 +- docs/gateway/security.md | 8 +- docs/gateway/tailscale.md | 9 +- docs/web/control-ui.md | 9 +- src/gateway/auth.test.ts | 23 ++++++ src/gateway/auth.ts | 71 ++++++++++++---- src/gateway/net.ts | 2 +- .../server/ws-connection/message-handler.ts | 4 + src/infra/tailscale.ts | 82 +++++++++++++++++++ 10 files changed, 189 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9742150a3..cb4570fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Status: unreleased. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. ### Fixes +- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. ## 2026.1.24-3 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 868126101..89fe7f784 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2878,10 +2878,11 @@ Auth and Tailscale: - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `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`. + with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot + verifies the identity by resolving the `x-forwarded-for` address via + `tailscale whois` before accepting it. 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 05e1673c6..1bdd014ba 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -333,9 +333,11 @@ Rotation checklist (token/password): 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. +authentication. Clawdbot 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. **Security rule:** do not forward these headers from your own reverse proxy. If you terminate TLS or proxy in front of the gateway, disable diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index b57ffcc33..e6477fbfc 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake: 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. +(`tailscale-user-login`) without supplying a token/password. Clawdbot 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. +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"`. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 188479679..996ed0fe4 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -70,10 +70,11 @@ Open: 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. +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 +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/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index aa4d5e270..90bd5c41e 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -125,6 +125,7 @@ describe("gateway auth", () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: true }, connectAuth: null, + tailscaleWhois: async () => ({ login: "peter", name: "Peter" }), req: { socket: { remoteAddress: "127.0.0.1" }, headers: { @@ -143,6 +144,28 @@ describe("gateway auth", () => { expect(res.user).toBe("peter"); }); + it("rejects mismatched tailscale identity when required", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }), + 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@example.com", + "tailscale-user-name": "Peter", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("tailscale_user_mismatch"); + }); + it("treats trusted proxy loopback clients as direct", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "none", allowTailscale: true }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cb4e868a2..0e0d1a7d5 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,7 +1,8 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; -import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js"; +import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; +import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { @@ -29,11 +30,17 @@ type TailscaleUser = { profilePic?: string; }; +type TailscaleWhoisLookup = (ip: string) => Promise; + function safeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +function normalizeLogin(login: string): string { + return login.trim().toLowerCase(); +} + function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) return false; if (ip === "127.0.0.1") return true; @@ -58,6 +65,12 @@ function headerValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } +function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { + if (!req) return undefined; + const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]); + return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined; +} + function resolveRequestClientIp( req?: IncomingMessage, trustedProxies?: string[], @@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean { return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req); } +async function resolveVerifiedTailscaleUser(params: { + req?: IncomingMessage; + tailscaleWhois: TailscaleWhoisLookup; +}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> { + const { req, tailscaleWhois } = params; + const tailscaleUser = getTailscaleUser(req); + if (!tailscaleUser) { + return { ok: false, reason: "tailscale_user_missing" }; + } + if (!isTailscaleProxyRequest(req)) { + return { ok: false, reason: "tailscale_proxy_missing" }; + } + const clientIp = resolveTailscaleClientIp(req); + if (!clientIp) { + return { ok: false, reason: "tailscale_whois_failed" }; + } + const whois = await tailscaleWhois(clientIp); + if (!whois?.login) { + return { ok: false, reason: "tailscale_whois_failed" }; + } + if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) { + return { ok: false, reason: "tailscale_user_mismatch" }; + } + return { + ok: true, + user: { + login: whois.login, + name: whois.name ?? tailscaleUser.name, + profilePic: tailscaleUser.profilePic, + }, + }; +} + export function resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; env?: NodeJS.ProcessEnv; @@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: { connectAuth?: ConnectAuth | null; req?: IncomingMessage; trustedProxies?: string[]; + tailscaleWhois?: TailscaleWhoisLookup; }): Promise { const { auth, connectAuth, req, trustedProxies } = params; + const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); if (auth.allowTailscale && !localDirect) { - const tailscaleUser = getTailscaleUser(req); - const tailscaleProxy = isTailscaleProxyRequest(req); - - if (tailscaleUser && tailscaleProxy) { + const tailscaleCheck = await resolveVerifiedTailscaleUser({ + req, + tailscaleWhois, + }); + if (tailscaleCheck.ok) { return { ok: true, method: "tailscale", - user: tailscaleUser.login, + user: tailscaleCheck.user.login, }; } - if (auth.mode === "none") { - if (!tailscaleUser) { - return { ok: false, reason: "tailscale_user_missing" }; - } - if (!tailscaleProxy) { - return { ok: false, reason: "tailscale_proxy_missing" }; - } + return { ok: false, reason: tailscaleCheck.reason }; } } @@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: { if (!connectAuth?.token) { return { ok: false, reason: "token_missing" }; } - if (connectAuth.token !== auth.token) { + if (!safeEqual(connectAuth.token, auth.token)) { return { ok: false, reason: "token_mismatch" }; } return { ok: true, method: "token" }; diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 608ec872f..6702e0e8b 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string { return ip; } -function parseForwardedForClientIp(forwardedFor?: string): string | undefined { +export function parseForwardedForClientIp(forwardedFor?: string): string | undefined { const raw = forwardedFor?.split(",")[0]?.trim(); if (!raw) return undefined; return normalizeIp(stripOptionalPort(raw)); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 35265ce63..7f8f9f2c6 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: { return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)"; case "tailscale_proxy_missing": return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)"; + case "tailscale_whois_failed": + return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)"; + case "tailscale_user_mismatch": + return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)"; default: break; } diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 8ff340184..2350670bb 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -213,6 +213,18 @@ type ExecErrorDetails = { code?: unknown; }; +export type TailscaleWhoisIdentity = { + login: string; + name?: string; +}; + +type TailscaleWhoisCacheEntry = { + value: TailscaleWhoisIdentity | null; + expiresAt: number; +}; + +const whoisCache = new Map(); + function extractExecErrorText(err: unknown) { const errOutput = err as ExecErrorDetails; const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; @@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { timeoutMs: 15_000, }); } + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function parseWhoisIdentity(payload: Record): TailscaleWhoisIdentity | null { + const userProfile = + readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User); + const login = + getString(userProfile?.LoginName) ?? + getString(userProfile?.Login) ?? + getString(userProfile?.login) ?? + getString(payload.LoginName) ?? + getString(payload.login); + if (!login) return null; + const name = + getString(userProfile?.DisplayName) ?? + getString(userProfile?.Name) ?? + getString(userProfile?.displayName) ?? + getString(payload.DisplayName) ?? + getString(payload.name); + return { login, name }; +} + +function readCachedWhois(ip: string, now: number): TailscaleWhoisIdentity | null | undefined { + const cached = whoisCache.get(ip); + if (!cached) return undefined; + if (cached.expiresAt <= now) { + whoisCache.delete(ip); + return undefined; + } + return cached.value; +} + +function writeCachedWhois(ip: string, value: TailscaleWhoisIdentity | null, ttlMs: number) { + whoisCache.set(ip, { value, expiresAt: Date.now() + ttlMs }); +} + +export async function readTailscaleWhoisIdentity( + ip: string, + exec: typeof runExec = runExec, + opts?: { timeoutMs?: number; cacheTtlMs?: number; errorTtlMs?: number }, +): Promise { + const normalized = ip.trim(); + if (!normalized) return null; + const now = Date.now(); + const cached = readCachedWhois(normalized, now); + if (cached !== undefined) return cached; + + const cacheTtlMs = opts?.cacheTtlMs ?? 60_000; + const errorTtlMs = opts?.errorTtlMs ?? 5_000; + try { + const tailscaleBin = await getTailscaleBinary(); + const { stdout } = await exec(tailscaleBin, ["whois", "--json", normalized], { + timeoutMs: opts?.timeoutMs ?? 5_000, + maxBuffer: 200_000, + }); + const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; + const identity = parseWhoisIdentity(parsed); + writeCachedWhois(normalized, identity, cacheTtlMs); + return identity; + } catch { + writeCachedWhois(normalized, null, errorTtlMs); + return null; + } +}