fix: honor trusted proxy client IPs (PR #1654)

Thanks @ndbroadbent.

Co-authored-by: Nathan Broadbent <git@ndbroadbent.com>
This commit is contained in:
Peter Steinberger
2026-01-25 01:51:31 +00:00
parent 2684a364c6
commit e6e71457e0
15 changed files with 189 additions and 20 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)

View File

@@ -2846,6 +2846,11 @@ Related docs:
- [Tailscale](/gateway/tailscale)
- [Remote access](/gateway/remote)
Trusted proxies:
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).

View File

@@ -322,6 +322,11 @@ Tailscale.
you terminate TLS or proxy in front of the gateway, disable
`gateway.auth.allowTailscale` and use token/password auth instead.
Trusted proxies:
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control server over Tailscale (recommended)

View File

@@ -218,4 +218,10 @@ export type GatewayConfig = {
tls?: GatewayTlsConfig;
http?: GatewayHttpConfig;
nodes?: GatewayNodesConfig;
/**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
*/
trustedProxies?: string[];
};

View File

@@ -324,6 +324,7 @@ export const ClawdbotSchema = z
})
.strict()
.optional(),
trustedProxies: z.array(z.string()).optional(),
tailscale: z
.object({
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),

View File

@@ -142,4 +142,19 @@ describe("gateway auth", () => {
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
});
it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
trustedProxies: ["10.0.0.2"],
req: {
socket: { remoteAddress: "10.0.0.2" },
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
});

View File

@@ -1,6 +1,7 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = {
@@ -53,9 +54,26 @@ function getHostName(hostHeader?: string): string {
return name ?? "";
}
function isLocalDirectRequest(req?: IncomingMessage): boolean {
function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function resolveRequestClientIp(
req?: IncomingMessage,
trustedProxies?: string[],
): string | undefined {
if (!req) return undefined;
return resolveGatewayClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
realIp: headerValue(req.headers?.["x-real-ip"]),
trustedProxies,
});
}
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
if (!req) return false;
const clientIp = req.socket?.remoteAddress ?? "";
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
if (!isLoopbackAddress(clientIp)) return false;
const host = getHostName(req.headers?.host);
@@ -68,7 +86,8 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
req.headers?.["x-forwarded-host"],
);
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
@@ -135,9 +154,10 @@ export async function authorizeGatewayConnect(params: {
auth: ResolvedGatewayAuth;
connectAuth?: ConnectAuth | null;
req?: IncomingMessage;
trustedProxies?: string[];
}): Promise<GatewayAuthResult> {
const { auth, connectAuth, req } = params;
const localDirect = isLocalDirectRequest(req);
const { auth, connectAuth, req, trustedProxies } = params;
const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req);

View File

@@ -8,7 +8,7 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
}));
import { isLocalGatewayAddress } from "./net.js";
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
describe("gateway net", () => {
beforeEach(() => {
@@ -38,4 +38,40 @@ describe("gateway net", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
test("uses forwarded-for when remote is a trusted proxy", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
forwardedFor: "203.0.113.9, 10.0.0.2",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("ignores forwarded-for from untrusted proxies", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.3",
forwardedFor: "203.0.113.9",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("10.0.0.3");
});
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "::ffff:10.0.0.2",
forwardedFor: "203.0.113.9:1234",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("falls back to x-real-ip when forwarded-for is missing", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
realIp: "203.0.113.10",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.10");
});
});

View File

@@ -16,6 +16,56 @@ function normalizeIPv4MappedAddress(ip: string): string {
return ip;
}
function normalizeIp(ip: string | undefined): string | undefined {
const trimmed = ip?.trim();
if (!trimmed) return undefined;
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
}
function stripOptionalPort(ip: string): string {
if (ip.startsWith("[")) {
const end = ip.indexOf("]");
if (end !== -1) return ip.slice(1, end);
}
if (net.isIP(ip)) return ip;
const lastColon = ip.lastIndexOf(":");
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
const candidate = ip.slice(0, lastColon);
if (net.isIP(candidate) === 4) return candidate;
}
return ip;
}
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
const raw = forwardedFor?.split(",")[0]?.trim();
if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw));
}
function parseRealIp(realIp?: string): string | undefined {
const raw = realIp?.trim();
if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw));
}
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
const normalized = normalizeIp(ip);
if (!normalized || !trustedProxies || trustedProxies.length === 0) return false;
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
}
export function resolveGatewayClientIp(params: {
remoteAddr?: string;
forwardedFor?: string;
realIp?: string;
trustedProxies?: string[];
}): string | undefined {
const remote = normalizeIp(params.remoteAddr);
if (!remote) return undefined;
if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote;
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
}
export function isLocalGatewayAddress(ip: string | undefined): boolean {
if (isLoopbackAddress(ip)) return true;
if (!ip) return false;

View File

@@ -20,6 +20,7 @@ import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./h
type OpenAiHttpOptions = {
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
trustedProxies?: string[];
};
type OpenAiChatMessage = {
@@ -168,6 +169,7 @@ export async function handleOpenAiHttpRequest(
auth: opts.auth,
connectAuth: { token, password: token },
req,
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);

View File

@@ -60,6 +60,7 @@ type OpenResponsesHttpOptions = {
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
config?: GatewayHttpResponsesConfig;
trustedProxies?: string[];
};
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
@@ -331,6 +332,7 @@ export async function handleOpenResponsesHttpRequest(
auth: opts.auth,
connectAuth: { token, password: token },
req,
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);

View File

@@ -227,21 +227,36 @@ export function createGatewayHttpServer(opts: {
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
try {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
if (
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
auth: resolvedAuth,
config: openResponsesConfig,
trustedProxies,
})
)
return;
}
if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
if (
await handleOpenAiHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
}
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;
@@ -251,14 +266,14 @@ export function createGatewayHttpServer(opts: {
if (
handleControlUiAvatarRequest(req, res, {
basePath: controlUiBasePath,
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
})
)
return;
if (
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
config: loadConfig(),
config: configSnapshot,
})
)
return;

View File

@@ -73,6 +73,7 @@ export function attachGatewayWsConnectionHandler(params: {
const requestOrigin = headerValue(upgradeReq.headers.origin);
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
const canvasHostOverride =
@@ -228,6 +229,7 @@ export function attachGatewayWsConnectionHandler(params: {
connId,
remoteAddr,
forwardedFor,
realIp,
requestHost,
requestOrigin,
requestUserAgent,

View File

@@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress } from "../../net.js";
import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -104,6 +104,7 @@ export function attachGatewayWsMessageHandler(params: {
connId: string;
remoteAddr?: string;
forwardedFor?: string;
realIp?: string;
requestHost?: string;
requestOrigin?: string;
requestUserAgent?: string;
@@ -133,6 +134,7 @@ export function attachGatewayWsMessageHandler(params: {
connId,
remoteAddr,
forwardedFor,
realIp,
requestHost,
requestOrigin,
requestUserAgent,
@@ -157,6 +159,11 @@ export function attachGatewayWsMessageHandler(params: {
logWsControl,
} = params;
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
const isLocalClient = isLocalGatewayAddress(clientIp);
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
socket.on("message", async (data) => {
@@ -300,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: {
if (!device) {
const allowInsecureControlUi =
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const canSkipDevice =
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
@@ -380,7 +387,7 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired");
return;
}
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
const nonceRequired = !isLocalClient;
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) {
setHandshakeState("failed");
@@ -495,6 +502,7 @@ export function attachGatewayWsMessageHandler(params: {
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
@@ -556,8 +564,8 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
silent: isLocalGatewayAddress(remoteAddr),
remoteIp: clientIp,
silent: isLocalClient,
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
@@ -640,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
remoteIp: clientIp,
});
}
}
@@ -689,7 +697,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
ip: isLocalClient ? undefined : clientIp,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
@@ -748,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: {
setHandshakeState("connected");
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp });
const instanceIdRaw = connectParams.client.instanceId;
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);

View File

@@ -70,7 +70,7 @@ function mergeActionIntoArgsIfSupported(params: {
export async function handleToolsInvokeHttpRequest(
req: IncomingMessage,
res: ServerResponse,
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] },
): Promise<boolean> {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
if (url.pathname !== "/tools/invoke") return false;
@@ -80,11 +80,13 @@ export async function handleToolsInvokeHttpRequest(
return true;
}
const cfg = loadConfig();
const token = getBearerToken(req);
const authResult = await authorizeGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
req,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);
@@ -110,7 +112,6 @@ export async function handleToolsInvokeHttpRequest(
: {}
) as Record<string, unknown>;
const cfg = loadConfig();
const rawSessionKey = resolveSessionKeyFromBody(body);
const sessionKey =
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;