diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cb4e868a2..0808d90f3 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,19 +1,24 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; -import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; +import type { + GatewayAuthConfig, + GatewayTailscaleMode, + GatewayTrustedProxyConfig, +} from "../config/config.js"; import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js"; -export type ResolvedGatewayAuthMode = "none" | "token" | "password"; +export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; token?: string; password?: string; allowTailscale: boolean; + trustedProxy?: GatewayTrustedProxyConfig; }; export type GatewayAuthResult = { ok: boolean; - method?: "none" | "token" | "password" | "tailscale" | "device-token"; + method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy"; user?: string; reason?: string; }; @@ -127,15 +132,30 @@ export function resolveGatewayAuth(params: { 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 mode: ResolvedGatewayAuth["mode"] = - authConfig.mode ?? (password ? "password" : token ? "token" : "none"); + const trustedProxy = authConfig.trustedProxy; + + // Determine auth mode: explicit > password > token > trusted-proxy > none + let mode: ResolvedGatewayAuth["mode"]; + if (authConfig.mode) { + mode = authConfig.mode; + } else if (password) { + mode = "password"; + } else if (token) { + mode = "token"; + } else { + mode = "none"; + } + const allowTailscale = - authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); + authConfig.allowTailscale ?? + (params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy"); + return { mode, token, password, allowTailscale, + trustedProxy, }; } @@ -148,6 +168,65 @@ 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"); } + 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)", + ); + } + } +} + +/** + * Check if the request came from a trusted proxy and extract user identity. + * Returns the user identity if valid, or null with a reason if not. + */ +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" }; + } + + // Verify the request came from a trusted proxy IP + const remoteAddr = req.socket?.remoteAddress; + if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) { + return { reason: "trusted_proxy_untrusted_source" }; + } + + // Check required headers are present + 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}` }; + } + } + + // Extract user identity from the configured header + const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]); + if (!userHeaderValue || userHeaderValue.trim() === "") { + return { reason: "trusted_proxy_user_missing" }; + } + + const user = userHeaderValue.trim(); + + // Check user allowlist if configured + const allowUsers = trustedProxyConfig.allowUsers ?? []; + if (allowUsers.length > 0 && !allowUsers.includes(user)) { + return { reason: "trusted_proxy_user_not_allowed" }; + } + + return { user }; } export async function authorizeGatewayConnect(params: { @@ -159,6 +238,27 @@ export async function authorizeGatewayConnect(params: { const { auth, connectAuth, req, trustedProxies } = params; const localDirect = isLocalDirectRequest(req, trustedProxies); + // Handle trusted-proxy auth mode first + 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.allowTailscale && !localDirect) { const tailscaleUser = getTailscaleUser(req); const tailscaleProxy = isTailscaleProxyRequest(req);