fix: harden tailscale serve auth
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user