mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 20:51:10 +00:00
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:
committed by
Peter Steinberger
parent
cd77ee076f
commit
c37081e612
@@ -1,5 +1,9 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
import type {
|
||||
GatewayAuthConfig,
|
||||
GatewayTailscaleMode,
|
||||
GatewayTrustedProxyConfig,
|
||||
} from "../config/config.js";
|
||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
import {
|
||||
@@ -14,18 +18,19 @@ import {
|
||||
resolveGatewayClientIp,
|
||||
} from "./net.js";
|
||||
|
||||
export type ResolvedGatewayAuthMode = "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?: "token" | "password" | "tailscale" | "device-token";
|
||||
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
|
||||
user?: string;
|
||||
reason?: string;
|
||||
/** Present when the request was blocked by the rate limiter. */
|
||||
@@ -192,21 +197,32 @@ export function resolveGatewayAuth(params: {
|
||||
}): ResolvedGatewayAuth {
|
||||
const authConfig = params.authConfig ?? {};
|
||||
const env = params.env ?? process.env;
|
||||
const token =
|
||||
authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ??
|
||||
env.OPENCLAW_GATEWAY_PASSWORD ??
|
||||
env.CLAWDBOT_GATEWAY_PASSWORD ??
|
||||
undefined;
|
||||
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
|
||||
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,6 +238,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: {
|
||||
@@ -241,7 +316,28 @@ export async function authorizeGatewayConnect(params: {
|
||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||
|
||||
// --- Rate-limit gate ---
|
||||
// Handle trusted-proxy auth mode first (bypasses rate limiting)
|
||||
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 };
|
||||
}
|
||||
|
||||
// --- Rate-limit gate (for token/password auth) ---
|
||||
const limiter = params.rateLimiter;
|
||||
const ip =
|
||||
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
|
||||
|
||||
Reference in New Issue
Block a user