From d43d4fcced2a7437ecb76fae801e7015d734f50e Mon Sep 17 00:00:00 2001 From: Palash Oswal Date: Fri, 16 Jan 2026 13:21:25 +0530 Subject: [PATCH] Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs (#885) * Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs * fix: allow local Tailscale Serve hostnames (#885) (thanks @oswalpalash) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/gateway/auth.test.ts | 28 ++++++++++++++++++++ src/gateway/auth.ts | 56 ++++++++++++++++++++++++++++++---------- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b023a40ce..c750de5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. - Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. - Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. +- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. ## 2026.1.14-1 diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index a6d2e145e..2354c7a46 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -93,6 +93,34 @@ describe("gateway auth", () => { expect(missingProxy.reason).toBe("tailscale_proxy_missing"); }); + it("treats local tailscale serve hostnames as direct", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { host: "gateway.tailnet-1234.ts.net:443" }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); + + it("does not treat tailscale clients as direct", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + req: { + socket: { remoteAddress: "100.64.0.42" }, + headers: { host: "gateway.tailnet-1234.ts.net" }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("tailscale_user_missing"); + }); + it("allows tailscale identity to satisfy token mode auth", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: true }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index b3baa77cf..924e06822 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,6 +1,9 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; -import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; +import type { + GatewayAuthConfig, + GatewayTailscaleMode, +} from "../config/config.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { @@ -42,20 +45,34 @@ function isLoopbackAddress(ip: string | undefined): boolean { return false; } +function getHostName(hostHeader?: string): string { + const host = (hostHeader ?? "").trim().toLowerCase(); + if (!host) return ""; + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end !== -1) return host.slice(1, end); + } + const [name] = host.split(":"); + return name ?? ""; +} + function isLocalDirectRequest(req?: IncomingMessage): boolean { if (!req) return false; const clientIp = req.socket?.remoteAddress ?? ""; if (!isLoopbackAddress(clientIp)) return false; - const host = (req.headers.host ?? "").toLowerCase(); + const host = getHostName(req.headers?.host); const hostIsLocal = - host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]"); + host === "localhost" || host === "127.0.0.1" || host === "::1"; + const hostIsTailscaleServe = host.endsWith(".ts.net"); const hasForwarded = Boolean( - req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"], + req.headers?.["x-forwarded-for"] || + req.headers?.["x-real-ip"] || + req.headers?.["x-forwarded-host"], ); - return hostIsLocal && !hasForwarded; + return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded; } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { @@ -64,11 +81,17 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { if (typeof login !== "string" || !login.trim()) return null; const nameRaw = req.headers["tailscale-user-name"]; const profilePic = req.headers["tailscale-user-profile-pic"]; - const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim(); + const name = + typeof nameRaw === "string" && nameRaw.trim() + ? nameRaw.trim() + : login.trim(); return { login: login.trim(), name, - profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined, + profilePic: + typeof profilePic === "string" && profilePic.trim() + ? profilePic.trim() + : undefined, }; } @@ -76,14 +99,17 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean { if (!req) return false; return Boolean( req.headers["x-forwarded-for"] && - req.headers["x-forwarded-proto"] && - req.headers["x-forwarded-host"], + req.headers["x-forwarded-proto"] && + req.headers["x-forwarded-host"], ); } function isTailscaleProxyRequest(req?: IncomingMessage): boolean { if (!req) return false; - return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req); + return ( + isLoopbackAddress(req.socket?.remoteAddress) && + hasTailscaleProxyHeaders(req) + ); } export function resolveGatewayAuth(params: { @@ -94,11 +120,13 @@ export function resolveGatewayAuth(params: { const authConfig = params.authConfig ?? {}; const env = params.env ?? process.env; const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; - const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; + const password = + authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : token ? "token" : "none"); const allowTailscale = - authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); + authConfig.allowTailscale ?? + (params.tailscaleMode === "serve" && mode !== "password"); return { mode, token, @@ -114,7 +142,9 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { ); } if (auth.mode === "password" && !auth.password) { - throw new Error("gateway auth mode is password, but no password was configured"); + throw new Error( + "gateway auth mode is password, but no password was configured", + ); } }