fix: harden tailscale serve auth

This commit is contained in:
Peter Steinberger
2026-01-26 12:47:53 +00:00
parent 6859e1e6a6
commit fd9be79be1
10 changed files with 189 additions and 29 deletions

View File

@@ -35,6 +35,7 @@ Status: unreleased.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
### Fixes ### 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. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
## 2026.1.24-3 ## 2026.1.24-3

View File

@@ -2878,10 +2878,11 @@ Auth and Tailscale:
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback (`tailscale-user-login`) to satisfy auth when the request arrives on loopback
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot
`true`, Serve requests do not need a token/password; set `false` to require verifies the identity by resolving the `x-forwarded-for` address via
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and `tailscale whois` before accepting it. When `true`, Serve requests do not need
auth mode is not `password`. 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: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

View File

@@ -333,9 +333,11 @@ Rotation checklist (token/password):
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
accepts Tailscale Serve identity headers (`tailscale-user-login`) as accepts Tailscale Serve identity headers (`tailscale-user-login`) as
authentication. This only triggers for requests that hit loopback and include authentication. Clawdbot verifies the identity by resolving the
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by `x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
Tailscale. 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 **Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable you terminate TLS or proxy in front of the gateway, disable

View File

@@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake:
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`, When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers valid Serve proxy requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. Clawdbot only (`tailscale-user-login`) without supplying a token/password. Clawdbot verifies
treats a request as Serve when it arrives from loopback with Tailscales the identity by resolving the `x-forwarded-for` address via the local Tailscale
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers. 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
Tailscales `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
headers.
To require explicit credentials, set `gateway.auth.allowTailscale: false` or To require explicit credentials, set `gateway.auth.allowTailscale: false` or
force `gateway.auth.mode: "password"`. force `gateway.auth.mode: "password"`.

View File

@@ -70,10 +70,11 @@ Open:
By default, Serve requests can authenticate via Tailscale identity headers By default, Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
only accepts these when the request hits loopback with Tailscales verifies the identity by resolving the `x-forwarded-for` address with
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force `tailscale whois` and matching it to the header, and only accepts these when the
`gateway.auth.mode: "password"`) if you want to require a token/password even request hits loopback with Tailscales `x-forwarded-*` headers. Set
for Serve traffic. `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 ### Bind to tailnet + token

View File

@@ -125,6 +125,7 @@ describe("gateway auth", () => {
const res = await authorizeGatewayConnect({ const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true }, auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null, connectAuth: null,
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
req: { req: {
socket: { remoteAddress: "127.0.0.1" }, socket: { remoteAddress: "127.0.0.1" },
headers: { headers: {
@@ -143,6 +144,28 @@ describe("gateway auth", () => {
expect(res.user).toBe("peter"); 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 () => { it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({ const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true }, auth: { mode: "none", allowTailscale: true },

View File

@@ -1,7 +1,8 @@
import { timingSafeEqual } from "node:crypto"; import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; 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 ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = { export type ResolvedGatewayAuth = {
@@ -29,11 +30,17 @@ type TailscaleUser = {
profilePic?: string; profilePic?: string;
}; };
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
function safeEqual(a: string, b: string): boolean { function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b)); return timingSafeEqual(Buffer.from(a), Buffer.from(b));
} }
function normalizeLogin(login: string): string {
return login.trim().toLowerCase();
}
function isLoopbackAddress(ip: string | undefined): boolean { function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false; if (!ip) return false;
if (ip === "127.0.0.1") return true; 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; 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( function resolveRequestClientIp(
req?: IncomingMessage, req?: IncomingMessage,
trustedProxies?: string[], trustedProxies?: string[],
@@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req); 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: { export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null; authConfig?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: {
connectAuth?: ConnectAuth | null; connectAuth?: ConnectAuth | null;
req?: IncomingMessage; req?: IncomingMessage;
trustedProxies?: string[]; trustedProxies?: string[];
tailscaleWhois?: TailscaleWhoisLookup;
}): Promise<GatewayAuthResult> { }): Promise<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params; const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const localDirect = isLocalDirectRequest(req, trustedProxies); const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.allowTailscale && !localDirect) { if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req); const tailscaleCheck = await resolveVerifiedTailscaleUser({
const tailscaleProxy = isTailscaleProxyRequest(req); req,
tailscaleWhois,
if (tailscaleUser && tailscaleProxy) { });
if (tailscaleCheck.ok) {
return { return {
ok: true, ok: true,
method: "tailscale", method: "tailscale",
user: tailscaleUser.login, user: tailscaleCheck.user.login,
}; };
} }
if (auth.mode === "none") { if (auth.mode === "none") {
if (!tailscaleUser) { return { ok: false, reason: tailscaleCheck.reason };
return { ok: false, reason: "tailscale_user_missing" };
}
if (!tailscaleProxy) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
} }
} }
@@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: {
if (!connectAuth?.token) { if (!connectAuth?.token) {
return { ok: false, reason: "token_missing" }; 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: false, reason: "token_mismatch" };
} }
return { ok: true, method: "token" }; return { ok: true, method: "token" };

View File

@@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string {
return ip; return ip;
} }
function parseForwardedForClientIp(forwardedFor?: string): string | undefined { export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
const raw = forwardedFor?.split(",")[0]?.trim(); const raw = forwardedFor?.split(",")[0]?.trim();
if (!raw) return undefined; if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw)); return normalizeIp(stripOptionalPort(raw));

View File

@@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: {
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)"; return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing": case "tailscale_proxy_missing":
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)"; 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: default:
break; break;
} }

View File

@@ -213,6 +213,18 @@ type ExecErrorDetails = {
code?: unknown; code?: unknown;
}; };
export type TailscaleWhoisIdentity = {
login: string;
name?: string;
};
type TailscaleWhoisCacheEntry = {
value: TailscaleWhoisIdentity | null;
expiresAt: number;
};
const whoisCache = new Map<string, TailscaleWhoisCacheEntry>();
function extractExecErrorText(err: unknown) { function extractExecErrorText(err: unknown) {
const errOutput = err as ExecErrorDetails; const errOutput = err as ExecErrorDetails;
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
@@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
timeoutMs: 15_000, timeoutMs: 15_000,
}); });
} }
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function parseWhoisIdentity(payload: Record<string, unknown>): 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<TailscaleWhoisIdentity | null> {
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;
}
}