feat(gateway): implement trusted-proxy auth logic

- Add 'trusted-proxy' to ResolvedGatewayAuthMode
- Add trustedProxy field to ResolvedGatewayAuth
- Add authorizeTrustedProxy() helper function
- Update authorizeGatewayConnect() to handle trusted-proxy mode
- Validate proxy source IP against trustedProxies list
- Support required headers and user allowlist validation

Part of #1560

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nick Taylor 2026-01-25 05:57:25 +00:00
parent b0dc39412f
commit 2acb4f47cc
No known key found for this signature in database

View File

@ -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);