Skip to content

Commit fd9be79

Browse files
committed
fix: harden tailscale serve auth
1 parent 6859e1e commit fd9be79

10 files changed

Lines changed: 189 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Status: unreleased.
3535
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
3636

3737
### Fixes
38+
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
3839
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
3940

4041
## 2026.1.24-3

docs/gateway/configuration.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2878,10 +2878,11 @@ Auth and Tailscale:
28782878
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
28792879
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
28802880
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
2881-
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
2882-
`true`, Serve requests do not need a token/password; set `false` to require
2883-
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
2884-
auth mode is not `password`.
2881+
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot
2882+
verifies the identity by resolving the `x-forwarded-for` address via
2883+
`tailscale whois` before accepting it. When `true`, Serve requests do not need
2884+
a token/password; set `false` to require explicit credentials. Defaults to
2885+
`true` when `tailscale.mode = "serve"` and auth mode is not `password`.
28852886
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
28862887
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
28872888
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

docs/gateway/security.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,11 @@ Rotation checklist (token/password):
333333

334334
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
335335
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
336-
authentication. This only triggers for requests that hit loopback and include
337-
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
338-
Tailscale.
336+
authentication. Clawdbot verifies the identity by resolving the
337+
`x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
338+
and matching it to the header. This only triggers for requests that hit loopback
339+
and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
340+
injected by Tailscale.
339341

340342
**Security rule:** do not forward these headers from your own reverse proxy. If
341343
you terminate TLS or proxy in front of the gateway, disable

docs/gateway/tailscale.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake:
2525

2626
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
2727
valid Serve proxy requests can authenticate via Tailscale identity headers
28-
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
29-
treats a request as Serve when it arrives from loopback with Tailscale’s
30-
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
28+
(`tailscale-user-login`) without supplying a token/password. Clawdbot verifies
29+
the identity by resolving the `x-forwarded-for` address via the local Tailscale
30+
daemon (`tailscale whois`) and matching it to the header before accepting it.
31+
Clawdbot only treats a request as Serve when it arrives from loopback with
32+
Tailscale’s `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
33+
headers.
3134
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
3235
force `gateway.auth.mode: "password"`.
3336

docs/web/control-ui.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ Open:
7070

7171
By default, Serve requests can authenticate via Tailscale identity headers
7272
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
73-
only accepts these when the request hits loopback with Tailscale’s
74-
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
75-
`gateway.auth.mode: "password"`) if you want to require a token/password even
76-
for Serve traffic.
73+
verifies the identity by resolving the `x-forwarded-for` address with
74+
`tailscale whois` and matching it to the header, and only accepts these when the
75+
request hits loopback with Tailscale’s `x-forwarded-*` headers. Set
76+
`gateway.auth.allowTailscale: false` (or force `gateway.auth.mode: "password"`)
77+
if you want to require a token/password even for Serve traffic.
7778

7879
### Bind to tailnet + token
7980

src/gateway/auth.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ describe("gateway auth", () => {
125125
const res = await authorizeGatewayConnect({
126126
auth: { mode: "token", token: "secret", allowTailscale: true },
127127
connectAuth: null,
128+
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
128129
req: {
129130
socket: { remoteAddress: "127.0.0.1" },
130131
headers: {
@@ -143,6 +144,28 @@ describe("gateway auth", () => {
143144
expect(res.user).toBe("peter");
144145
});
145146

147+
it("rejects mismatched tailscale identity when required", async () => {
148+
const res = await authorizeGatewayConnect({
149+
auth: { mode: "none", allowTailscale: true },
150+
connectAuth: null,
151+
tailscaleWhois: async () => ({ login: "[email protected]", name: "Alice" }),
152+
req: {
153+
socket: { remoteAddress: "127.0.0.1" },
154+
headers: {
155+
host: "gateway.local",
156+
"x-forwarded-for": "100.64.0.1",
157+
"x-forwarded-proto": "https",
158+
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
159+
"tailscale-user-login": "[email protected]",
160+
"tailscale-user-name": "Peter",
161+
},
162+
} as never,
163+
});
164+
165+
expect(res.ok).toBe(false);
166+
expect(res.reason).toBe("tailscale_user_mismatch");
167+
});
168+
146169
it("treats trusted proxy loopback clients as direct", async () => {
147170
const res = await authorizeGatewayConnect({
148171
auth: { mode: "none", allowTailscale: true },

src/gateway/auth.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { timingSafeEqual } from "node:crypto";
22
import type { IncomingMessage } from "node:http";
33
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
4-
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
4+
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
5+
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
56
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
67

78
export type ResolvedGatewayAuth = {
@@ -29,11 +30,17 @@ type TailscaleUser = {
2930
profilePic?: string;
3031
};
3132

33+
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
34+
3235
function safeEqual(a: string, b: string): boolean {
3336
if (a.length !== b.length) return false;
3437
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
3538
}
3639

40+
function normalizeLogin(login: string): string {
41+
return login.trim().toLowerCase();
42+
}
43+
3744
function isLoopbackAddress(ip: string | undefined): boolean {
3845
if (!ip) return false;
3946
if (ip === "127.0.0.1") return true;
@@ -58,6 +65,12 @@ function headerValue(value: string | string[] | undefined): string | undefined {
5865
return Array.isArray(value) ? value[0] : value;
5966
}
6067

68+
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
69+
if (!req) return undefined;
70+
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
71+
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
72+
}
73+
6174
function resolveRequestClientIp(
6275
req?: IncomingMessage,
6376
trustedProxies?: string[],
@@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
118131
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
119132
}
120133

134+
async function resolveVerifiedTailscaleUser(params: {
135+
req?: IncomingMessage;
136+
tailscaleWhois: TailscaleWhoisLookup;
137+
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
138+
const { req, tailscaleWhois } = params;
139+
const tailscaleUser = getTailscaleUser(req);
140+
if (!tailscaleUser) {
141+
return { ok: false, reason: "tailscale_user_missing" };
142+
}
143+
if (!isTailscaleProxyRequest(req)) {
144+
return { ok: false, reason: "tailscale_proxy_missing" };
145+
}
146+
const clientIp = resolveTailscaleClientIp(req);
147+
if (!clientIp) {
148+
return { ok: false, reason: "tailscale_whois_failed" };
149+
}
150+
const whois = await tailscaleWhois(clientIp);
151+
if (!whois?.login) {
152+
return { ok: false, reason: "tailscale_whois_failed" };
153+
}
154+
if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) {
155+
return { ok: false, reason: "tailscale_user_mismatch" };
156+
}
157+
return {
158+
ok: true,
159+
user: {
160+
login: whois.login,
161+
name: whois.name ?? tailscaleUser.name,
162+
profilePic: tailscaleUser.profilePic,
163+
},
164+
};
165+
}
166+
121167
export function resolveGatewayAuth(params: {
122168
authConfig?: GatewayAuthConfig | null;
123169
env?: NodeJS.ProcessEnv;
@@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: {
155201
connectAuth?: ConnectAuth | null;
156202
req?: IncomingMessage;
157203
trustedProxies?: string[];
204+
tailscaleWhois?: TailscaleWhoisLookup;
158205
}): Promise<GatewayAuthResult> {
159206
const { auth, connectAuth, req, trustedProxies } = params;
207+
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
160208
const localDirect = isLocalDirectRequest(req, trustedProxies);
161209

162210
if (auth.allowTailscale && !localDirect) {
163-
const tailscaleUser = getTailscaleUser(req);
164-
const tailscaleProxy = isTailscaleProxyRequest(req);
165-
166-
if (tailscaleUser && tailscaleProxy) {
211+
const tailscaleCheck = await resolveVerifiedTailscaleUser({
212+
req,
213+
tailscaleWhois,
214+
});
215+
if (tailscaleCheck.ok) {
167216
return {
168217
ok: true,
169218
method: "tailscale",
170-
user: tailscaleUser.login,
219+
user: tailscaleCheck.user.login,
171220
};
172221
}
173-
174222
if (auth.mode === "none") {
175-
if (!tailscaleUser) {
176-
return { ok: false, reason: "tailscale_user_missing" };
177-
}
178-
if (!tailscaleProxy) {
179-
return { ok: false, reason: "tailscale_proxy_missing" };
180-
}
223+
return { ok: false, reason: tailscaleCheck.reason };
181224
}
182225
}
183226

@@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: {
192235
if (!connectAuth?.token) {
193236
return { ok: false, reason: "token_missing" };
194237
}
195-
if (connectAuth.token !== auth.token) {
238+
if (!safeEqual(connectAuth.token, auth.token)) {
196239
return { ok: false, reason: "token_mismatch" };
197240
}
198241
return { ok: true, method: "token" };

src/gateway/net.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string {
3636
return ip;
3737
}
3838

39-
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
39+
export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
4040
const raw = forwardedFor?.split(",")[0]?.trim();
4141
if (!raw) return undefined;
4242
return normalizeIp(stripOptionalPort(raw));

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: {
100100
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
101101
case "tailscale_proxy_missing":
102102
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
103+
case "tailscale_whois_failed":
104+
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
105+
case "tailscale_user_mismatch":
106+
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
103107
default:
104108
break;
105109
}

src/infra/tailscale.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ type ExecErrorDetails = {
213213
code?: unknown;
214214
};
215215

216+
export type TailscaleWhoisIdentity = {
217+
login: string;
218+
name?: string;
219+
};
220+
221+
type TailscaleWhoisCacheEntry = {
222+
value: TailscaleWhoisIdentity | null;
223+
expiresAt: number;
224+
};
225+
226+
const whoisCache = new Map<string, TailscaleWhoisCacheEntry>();
227+
216228
function extractExecErrorText(err: unknown) {
217229
const errOutput = err as ExecErrorDetails;
218230
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
@@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
381393
timeoutMs: 15_000,
382394
});
383395
}
396+
397+
function getString(value: unknown): string | undefined {
398+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
399+
}
400+
401+
function readRecord(value: unknown): Record<string, unknown> | null {
402+
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
403+
}
404+
405+
function parseWhoisIdentity(payload: Record<string, unknown>): TailscaleWhoisIdentity | null {
406+
const userProfile =
407+
readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User);
408+
const login =
409+
getString(userProfile?.LoginName) ??
410+
getString(userProfile?.Login) ??
411+
getString(userProfile?.login) ??
412+
getString(payload.LoginName) ??
413+
getString(payload.login);
414+
if (!login) return null;
415+
const name =
416+
getString(userProfile?.DisplayName) ??
417+
getString(userProfile?.Name) ??
418+
getString(userProfile?.displayName) ??
419+
getString(payload.DisplayName) ??
420+
getString(payload.name);
421+
return { login, name };
422+
}
423+
424+
function readCachedWhois(ip: string, now: number): TailscaleWhoisIdentity | null | undefined {
425+
const cached = whoisCache.get(ip);
426+
if (!cached) return undefined;
427+
if (cached.expiresAt <= now) {
428+
whoisCache.delete(ip);
429+
return undefined;
430+
}
431+
return cached.value;
432+
}
433+
434+
function writeCachedWhois(ip: string, value: TailscaleWhoisIdentity | null, ttlMs: number) {
435+
whoisCache.set(ip, { value, expiresAt: Date.now() + ttlMs });
436+
}
437+
438+
export async function readTailscaleWhoisIdentity(
439+
ip: string,
440+
exec: typeof runExec = runExec,
441+
opts?: { timeoutMs?: number; cacheTtlMs?: number; errorTtlMs?: number },
442+
): Promise<TailscaleWhoisIdentity | null> {
443+
const normalized = ip.trim();
444+
if (!normalized) return null;
445+
const now = Date.now();
446+
const cached = readCachedWhois(normalized, now);
447+
if (cached !== undefined) return cached;
448+
449+
const cacheTtlMs = opts?.cacheTtlMs ?? 60_000;
450+
const errorTtlMs = opts?.errorTtlMs ?? 5_000;
451+
try {
452+
const tailscaleBin = await getTailscaleBinary();
453+
const { stdout } = await exec(tailscaleBin, ["whois", "--json", normalized], {
454+
timeoutMs: opts?.timeoutMs ?? 5_000,
455+
maxBuffer: 200_000,
456+
});
457+
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
458+
const identity = parseWhoisIdentity(parsed);
459+
writeCachedWhois(normalized, identity, cacheTtlMs);
460+
return identity;
461+
} catch {
462+
writeCachedWhois(normalized, null, errorTtlMs);
463+
return null;
464+
}
465+
}

0 commit comments

Comments
 (0)