11import { timingSafeEqual } from "node:crypto" ;
22import type { IncomingMessage } from "node:http" ;
33import 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" ;
56export type ResolvedGatewayAuthMode = "none" | "token" | "password" ;
67
78export type ResolvedGatewayAuth = {
@@ -29,11 +30,17 @@ type TailscaleUser = {
2930 profilePic ?: string ;
3031} ;
3132
33+ type TailscaleWhoisLookup = ( ip : string ) => Promise < TailscaleWhoisIdentity | null > ;
34+
3235function 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+
3744function 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+
6174function 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+
121167export 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" } ;
0 commit comments