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

@@ -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<TailscaleWhoisIdentity | null>;
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<GatewayAuthResult> {
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" };