| import type { IncomingMessage } from "node:http"; |
| import type { |
| GatewayAuthConfig, |
| GatewayTailscaleMode, |
| GatewayTrustedProxyConfig, |
| } from "../config/config.js"; |
| import { resolveSecretInputRef } from "../config/types.secrets.js"; |
| import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; |
| import { safeEqualSecret } from "../security/secret-equal.js"; |
| import { |
| AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, |
| type AuthRateLimiter, |
| type RateLimitCheckResult, |
| } from "./auth-rate-limit.js"; |
| import { resolveGatewayCredentialsFromValues } from "./credentials.js"; |
| import { |
| isLocalishHost, |
| isLoopbackAddress, |
| resolveRequestClientIp, |
| isTrustedProxyAddress, |
| resolveClientIp, |
| } from "./net.js"; |
|
|
| export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; |
| export type ResolvedGatewayAuthModeSource = |
| | "override" |
| | "config" |
| | "password" |
| | "token" |
| | "default"; |
|
|
| export type ResolvedGatewayAuth = { |
| mode: ResolvedGatewayAuthMode; |
| modeSource?: ResolvedGatewayAuthModeSource; |
| token?: string; |
| password?: string; |
| allowTailscale: boolean; |
| trustedProxy?: GatewayTrustedProxyConfig; |
| }; |
|
|
| export type GatewayAuthResult = { |
| ok: boolean; |
| method?: |
| | "none" |
| | "token" |
| | "password" |
| | "tailscale" |
| | "device-token" |
| | "bootstrap-token" |
| | "trusted-proxy"; |
| user?: string; |
| reason?: string; |
| |
| rateLimited?: boolean; |
| |
| retryAfterMs?: number; |
| }; |
|
|
| type ConnectAuth = { |
| token?: string; |
| password?: string; |
| }; |
|
|
| export type GatewayAuthSurface = "http" | "ws-control-ui"; |
|
|
| export type AuthorizeGatewayConnectParams = { |
| auth: ResolvedGatewayAuth; |
| connectAuth?: ConnectAuth | null; |
| req?: IncomingMessage; |
| trustedProxies?: string[]; |
| tailscaleWhois?: TailscaleWhoisLookup; |
| |
| |
| |
| |
| authSurface?: GatewayAuthSurface; |
| |
| rateLimiter?: AuthRateLimiter; |
| |
| clientIp?: string; |
| |
| rateLimitScope?: string; |
| |
| allowRealIpFallback?: boolean; |
| }; |
|
|
| type TailscaleUser = { |
| login: string; |
| name: string; |
| profilePic?: string; |
| }; |
|
|
| type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>; |
|
|
| function normalizeLogin(login: string): string { |
| return login.trim().toLowerCase(); |
| } |
|
|
| function headerValue(value: string | string[] | undefined): string | undefined { |
| return Array.isArray(value) ? value[0] : value; |
| } |
|
|
| const TAILSCALE_TRUSTED_PROXIES = ["127.0.0.1", "::1"] as const; |
|
|
| function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { |
| if (!req) { |
| return undefined; |
| } |
| return resolveClientIp({ |
| remoteAddr: req.socket?.remoteAddress ?? "", |
| forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), |
| trustedProxies: [...TAILSCALE_TRUSTED_PROXIES], |
| }); |
| } |
|
|
| export function isLocalDirectRequest( |
| req?: IncomingMessage, |
| trustedProxies?: string[], |
| allowRealIpFallback = false, |
| ): boolean { |
| if (!req) { |
| return false; |
| } |
| const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? ""; |
| if (!isLoopbackAddress(clientIp)) { |
| return false; |
| } |
|
|
| const hasForwarded = Boolean( |
| req.headers?.["x-forwarded-for"] || |
| req.headers?.["x-real-ip"] || |
| req.headers?.["x-forwarded-host"], |
| ); |
|
|
| const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); |
| return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy); |
| } |
|
|
| function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { |
| if (!req) { |
| return null; |
| } |
| const login = req.headers["tailscale-user-login"]; |
| 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(); |
| return { |
| login: login.trim(), |
| name, |
| profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined, |
| }; |
| } |
|
|
| 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"], |
| ); |
| } |
|
|
| function isTailscaleProxyRequest(req?: IncomingMessage): boolean { |
| if (!req) { |
| return false; |
| } |
| 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; |
| authOverride?: GatewayAuthConfig | null; |
| env?: NodeJS.ProcessEnv; |
| tailscaleMode?: GatewayTailscaleMode; |
| }): ResolvedGatewayAuth { |
| const baseAuthConfig = params.authConfig ?? {}; |
| const authOverride = params.authOverride ?? undefined; |
| const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; |
| if (authOverride) { |
| if (authOverride.mode !== undefined) { |
| authConfig.mode = authOverride.mode; |
| } |
| if (authOverride.token !== undefined) { |
| authConfig.token = authOverride.token; |
| } |
| if (authOverride.password !== undefined) { |
| authConfig.password = authOverride.password; |
| } |
| if (authOverride.allowTailscale !== undefined) { |
| authConfig.allowTailscale = authOverride.allowTailscale; |
| } |
| if (authOverride.rateLimit !== undefined) { |
| authConfig.rateLimit = authOverride.rateLimit; |
| } |
| if (authOverride.trustedProxy !== undefined) { |
| authConfig.trustedProxy = authOverride.trustedProxy; |
| } |
| } |
| const env = params.env ?? process.env; |
| const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; |
| const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; |
| const resolvedCredentials = resolveGatewayCredentialsFromValues({ |
| configToken: tokenRef ? undefined : authConfig.token, |
| configPassword: passwordRef ? undefined : authConfig.password, |
| env, |
| includeLegacyEnv: false, |
| tokenPrecedence: "config-first", |
| passwordPrecedence: "config-first", |
| }); |
| const token = resolvedCredentials.token; |
| const password = resolvedCredentials.password; |
| const trustedProxy = authConfig.trustedProxy; |
|
|
| let mode: ResolvedGatewayAuth["mode"]; |
| let modeSource: ResolvedGatewayAuth["modeSource"]; |
| if (authOverride?.mode !== undefined) { |
| mode = authOverride.mode; |
| modeSource = "override"; |
| } else if (authConfig.mode) { |
| mode = authConfig.mode; |
| modeSource = "config"; |
| } else if (password) { |
| mode = "password"; |
| modeSource = "password"; |
| } else if (token) { |
| mode = "token"; |
| modeSource = "token"; |
| } else { |
| mode = "token"; |
| modeSource = "default"; |
| } |
|
|
| const allowTailscale = |
| authConfig.allowTailscale ?? |
| (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); |
|
|
| return { |
| mode, |
| modeSource, |
| token, |
| password, |
| allowTailscale, |
| trustedProxy, |
| }; |
| } |
|
|
| export function assertGatewayAuthConfigured( |
| auth: ResolvedGatewayAuth, |
| rawAuthConfig?: GatewayAuthConfig | null, |
| ): void { |
| if (auth.mode === "token" && !auth.token) { |
| if (auth.allowTailscale) { |
| return; |
| } |
| throw new Error( |
| "gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", |
| ); |
| } |
| if (auth.mode === "password" && !auth.password) { |
| if ( |
| rawAuthConfig?.password != null && |
| typeof rawAuthConfig.password !== "string" |
| ) { |
| throw new Error( |
| "gateway auth mode is password, but gateway.auth.password contains a provider reference object instead of a resolved string — bootstrap secrets (gateway.auth.password) must be plaintext strings or set via the OPENCLAW_GATEWAY_PASSWORD environment variable because the secrets provider system has not initialised yet at gateway startup", |
| ); |
| } |
| throw new Error("gateway auth mode is password, but no password was configured"); |
| } |
| if (auth.mode === "trusted-proxy") { |
| if (!auth.trustedProxy) { |
| throw new Error( |
| "gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)", |
| ); |
| } |
| if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") { |
| throw new Error( |
| "gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)", |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| function authorizeTrustedProxy(params: { |
| req?: IncomingMessage; |
| trustedProxies?: string[]; |
| trustedProxyConfig: GatewayTrustedProxyConfig; |
| }): { user: string } | { reason: string } { |
| const { req, trustedProxies, trustedProxyConfig } = params; |
|
|
| if (!req) { |
| return { reason: "trusted_proxy_no_request" }; |
| } |
|
|
| const remoteAddr = req.socket?.remoteAddress; |
| if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) { |
| return { reason: "trusted_proxy_untrusted_source" }; |
| } |
|
|
| const requiredHeaders = trustedProxyConfig.requiredHeaders ?? []; |
| for (const header of requiredHeaders) { |
| const value = headerValue(req.headers[header.toLowerCase()]); |
| if (!value || value.trim() === "") { |
| return { reason: `trusted_proxy_missing_header_${header}` }; |
| } |
| } |
|
|
| const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]); |
| if (!userHeaderValue || userHeaderValue.trim() === "") { |
| return { reason: "trusted_proxy_user_missing" }; |
| } |
|
|
| const user = userHeaderValue.trim(); |
|
|
| const allowUsers = trustedProxyConfig.allowUsers ?? []; |
| if (allowUsers.length > 0 && !allowUsers.includes(user)) { |
| return { reason: "trusted_proxy_user_not_allowed" }; |
| } |
|
|
| return { user }; |
| } |
|
|
| function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolean { |
| return authSurface === "ws-control-ui"; |
| } |
|
|
| export async function authorizeGatewayConnect( |
| params: AuthorizeGatewayConnectParams, |
| ): Promise<GatewayAuthResult> { |
| const { auth, connectAuth, req, trustedProxies } = params; |
| const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; |
| const authSurface = params.authSurface ?? "http"; |
| const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface); |
| const localDirect = isLocalDirectRequest( |
| req, |
| trustedProxies, |
| params.allowRealIpFallback === true, |
| ); |
|
|
| if (auth.mode === "trusted-proxy") { |
| if (!auth.trustedProxy) { |
| return { ok: false, reason: "trusted_proxy_config_missing" }; |
| } |
| if (!trustedProxies || trustedProxies.length === 0) { |
| return { ok: false, reason: "trusted_proxy_no_proxies_configured" }; |
| } |
|
|
| const result = authorizeTrustedProxy({ |
| req, |
| trustedProxies, |
| trustedProxyConfig: auth.trustedProxy, |
| }); |
|
|
| if ("user" in result) { |
| return { ok: true, method: "trusted-proxy", user: result.user }; |
| } |
| return { ok: false, reason: result.reason }; |
| } |
|
|
| if (auth.mode === "none") { |
| return { ok: true, method: "none" }; |
| } |
|
|
| const limiter = params.rateLimiter; |
| const ip = |
| params.clientIp ?? |
| resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? |
| req?.socket?.remoteAddress; |
| const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; |
| if (limiter) { |
| const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); |
| if (!rlCheck.allowed) { |
| return { |
| ok: false, |
| reason: "rate_limited", |
| rateLimited: true, |
| retryAfterMs: rlCheck.retryAfterMs, |
| }; |
| } |
| } |
|
|
| if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) { |
| const tailscaleCheck = await resolveVerifiedTailscaleUser({ |
| req, |
| tailscaleWhois, |
| }); |
| if (tailscaleCheck.ok) { |
| limiter?.reset(ip, rateLimitScope); |
| return { |
| ok: true, |
| method: "tailscale", |
| user: tailscaleCheck.user.login, |
| }; |
| } |
| } |
|
|
| if (auth.mode === "token") { |
| if (!auth.token) { |
| return { ok: false, reason: "token_missing_config" }; |
| } |
| if (!connectAuth?.token) { |
| |
| |
| |
| return { ok: false, reason: "token_missing" }; |
| } |
| if (!safeEqualSecret(connectAuth.token, auth.token)) { |
| limiter?.recordFailure(ip, rateLimitScope); |
| return { ok: false, reason: "token_mismatch" }; |
| } |
| limiter?.reset(ip, rateLimitScope); |
| return { ok: true, method: "token" }; |
| } |
|
|
| if (auth.mode === "password") { |
| const password = connectAuth?.password; |
| if (!auth.password) { |
| return { ok: false, reason: "password_missing_config" }; |
| } |
| if (!password) { |
| |
| return { ok: false, reason: "password_missing" }; |
| } |
| if (!safeEqualSecret(password, auth.password)) { |
| limiter?.recordFailure(ip, rateLimitScope); |
| return { ok: false, reason: "password_mismatch" }; |
| } |
| limiter?.reset(ip, rateLimitScope); |
| return { ok: true, method: "password" }; |
| } |
|
|
| limiter?.recordFailure(ip, rateLimitScope); |
| return { ok: false, reason: "unauthorized" }; |
| } |
|
|
| export async function authorizeHttpGatewayConnect( |
| params: Omit<AuthorizeGatewayConnectParams, "authSurface">, |
| ): Promise<GatewayAuthResult> { |
| return authorizeGatewayConnect({ |
| ...params, |
| authSurface: "http", |
| }); |
| } |
|
|
| export async function authorizeWsControlUiGatewayConnect( |
| params: Omit<AuthorizeGatewayConnectParams, "authSurface">, |
| ): Promise<GatewayAuthResult> { |
| return authorizeGatewayConnect({ |
| ...params, |
| authSurface: "ws-control-ui", |
| }); |
| } |
|
|