refactor(gateway): harden proxy client ip resolution

This commit is contained in:
Peter Steinberger
2026-02-21 13:32:25 +01:00
parent 8b1fe0d1e2
commit be7f825006
15 changed files with 246 additions and 87 deletions

View File

@@ -310,10 +310,15 @@ export type GatewayConfig = {
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.
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for`
* to determine the client IP for local pairing and HTTP checks.
*/
trustedProxies?: string[];
/**
* Allow `x-real-ip` as a fallback only when `x-forwarded-for` is missing.
* Default: false (safer fail-closed behavior).
*/
allowRealIpFallback?: boolean;
/** Tool access restrictions for HTTP /tools/invoke endpoint. */
tools?: GatewayToolsConfig;
/**

View File

@@ -450,6 +450,7 @@ export const OpenClawSchema = z
.strict()
.optional(),
trustedProxies: z.array(z.string()).optional(),
allowRealIpFallback: z.boolean().optional(),
tools: z
.object({
deny: z.array(z.string()).optional(),

View File

@@ -301,6 +301,45 @@ describe("gateway auth", () => {
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.10", "shared-secret");
});
it("ignores X-Real-IP fallback by default for rate-limit checks", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "wrong" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-real-ip": "203.0.113.77" },
} as never,
trustedProxies: ["127.0.0.1"],
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_mismatch");
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
});
it("uses X-Real-IP when fallback is explicitly enabled", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "wrong" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-real-ip": "203.0.113.77" },
} as never,
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: true,
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_mismatch");
expect(limiter.check).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
});
it("passes custom rate-limit scope to limiter operations", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({

View File

@@ -15,8 +15,7 @@ import {
isLoopbackAddress,
isTrustedProxyAddress,
resolveHostName,
parseForwardedForClientIp,
resolveGatewayClientIp,
resolveClientIp,
} from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
@@ -71,6 +70,8 @@ export type AuthorizeGatewayConnectParams = {
clientIp?: string;
/** Optional limiter scope; defaults to shared-secret auth scope. */
rateLimitScope?: string;
/** Trust X-Real-IP only when explicitly enabled. */
allowRealIpFallback?: boolean;
};
type TailscaleUser = {
@@ -89,34 +90,45 @@ 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;
}
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
return resolveClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
trustedProxies: [...TAILSCALE_TRUSTED_PROXIES],
});
}
function resolveRequestClientIp(
req?: IncomingMessage,
trustedProxies?: string[],
allowRealIpFallback = false,
): string | undefined {
if (!req) {
return undefined;
}
return resolveGatewayClientIp({
return resolveClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
realIp: headerValue(req.headers?.["x-real-ip"]),
trustedProxies,
allowRealIpFallback,
});
}
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
export function isLocalDirectRequest(
req?: IncomingMessage,
trustedProxies?: string[],
allowRealIpFallback = false,
): boolean {
if (!req) {
return false;
}
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? "";
if (!isLoopbackAddress(clientIp)) {
return false;
}
@@ -351,7 +363,11 @@ export async function authorizeGatewayConnect(
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const authSurface = params.authSurface ?? "http";
const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface);
const localDirect = isLocalDirectRequest(req, trustedProxies);
const localDirect = isLocalDirectRequest(
req,
trustedProxies,
params.allowRealIpFallback === true,
);
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) {
@@ -379,7 +395,9 @@ export async function authorizeGatewayConnect(
const limiter = params.rateLimiter;
const ip =
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
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);

View File

@@ -9,6 +9,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
res: ServerResponse;
auth: ResolvedGatewayAuth;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<boolean> {
const token = getBearerToken(params.req);
@@ -17,6 +18,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
connectAuth: token ? { token, password: token } : null,
req: params.req,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!authResult.ok) {

View File

@@ -12,6 +12,7 @@ export async function handleGatewayPostJsonEndpoint(
auth: ResolvedGatewayAuth;
maxBodyBytes: number;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
},
): Promise<false | { body: unknown } | undefined> {
@@ -30,6 +31,7 @@ export async function handleGatewayPostJsonEndpoint(
res,
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
});
if (!authorized) {

View File

@@ -5,7 +5,7 @@ import {
isSecureWebSocketUrl,
isTrustedProxyAddress,
pickPrimaryLanIPv4,
resolveGatewayClientIp,
resolveClientIp,
resolveGatewayListenHosts,
resolveHostName,
} from "./net.js";
@@ -132,49 +132,74 @@ describe("isTrustedProxyAddress", () => {
});
});
describe("resolveGatewayClientIp", () => {
it("returns remote IP when the remote is not a trusted proxy", () => {
const ip = resolveGatewayClientIp({
describe("resolveClientIp", () => {
it.each([
{
name: "returns remote IP when remote is not trusted proxy",
remoteAddr: "203.0.113.10",
forwardedFor: "10.0.0.2",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("203.0.113.10");
});
it("returns forwarded client IP when the remote is a trusted proxy", () => {
const ip = resolveGatewayClientIp({
remoteAddr: "127.0.0.1",
forwardedFor: "127.0.0.1, 10.0.0.2",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("10.0.0.2");
});
it("does not trust the left-most X-Forwarded-For value when behind a trusted proxy", () => {
const ip = resolveGatewayClientIp({
expected: "203.0.113.10",
},
{
name: "uses right-most untrusted X-Forwarded-For hop",
remoteAddr: "127.0.0.1",
forwardedFor: "198.51.100.99, 10.0.0.9, 127.0.0.1",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBe("10.0.0.9");
});
it("fails closed when trusted proxy headers are missing", () => {
const ip = resolveGatewayClientIp({
expected: "10.0.0.9",
},
{
name: "fails closed when all X-Forwarded-For hops are trusted proxies",
remoteAddr: "127.0.0.1",
forwardedFor: "127.0.0.1, ::1",
trustedProxies: ["127.0.0.1", "::1"],
expected: undefined,
},
{
name: "fails closed when trusted proxy omits forwarding headers",
remoteAddr: "127.0.0.1",
trustedProxies: ["127.0.0.1"],
});
expect(ip).toBeUndefined();
});
it("supports IPv6 client IP forwarded by a trusted proxy", () => {
const ip = resolveGatewayClientIp({
expected: undefined,
},
{
name: "ignores invalid X-Forwarded-For entries",
remoteAddr: "127.0.0.1",
forwardedFor: "garbage, 10.0.0.999",
trustedProxies: ["127.0.0.1"],
expected: undefined,
},
{
name: "does not trust X-Real-IP by default",
remoteAddr: "127.0.0.1",
realIp: "[2001:db8::5]",
trustedProxies: ["127.0.0.1"],
expected: undefined,
},
{
name: "uses X-Real-IP only when explicitly enabled",
remoteAddr: "127.0.0.1",
realIp: "[2001:db8::5]",
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: true,
expected: "2001:db8::5",
},
{
name: "ignores invalid X-Real-IP even when fallback enabled",
remoteAddr: "127.0.0.1",
realIp: "not-an-ip",
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: true,
expected: undefined,
},
])("$name", (testCase) => {
const ip = resolveClientIp({
remoteAddr: testCase.remoteAddr,
forwardedFor: testCase.forwardedFor,
realIp: testCase.realIp,
trustedProxies: testCase.trustedProxies,
allowRealIpFallback: testCase.allowRealIpFallback,
});
expect(ip).toBe("2001:db8::5");
expect(ip).toBe(testCase.expected);
});
});

View File

@@ -146,45 +146,51 @@ function stripOptionalPort(ip: string): string {
return ip;
}
export function parseForwardedForClientIp(
forwardedFor?: string,
trustedProxies?: string[],
): string | undefined {
const entries = forwardedFor
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (!entries?.length) {
function parseIpLiteral(raw: string | undefined): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
if (!trustedProxies?.length) {
const raw = entries.at(-1);
if (!raw) {
return undefined;
}
return normalizeIp(stripOptionalPort(raw));
const stripped = stripOptionalPort(trimmed);
const normalized = normalizeIp(stripped);
if (!normalized || net.isIP(normalized) === 0) {
return undefined;
}
for (let index = entries.length - 1; index >= 0; index -= 1) {
const normalized = normalizeIp(stripOptionalPort(entries[index]));
if (!normalized) {
continue;
}
if (!isTrustedProxyAddress(normalized, trustedProxies)) {
return normalized;
}
}
return undefined;
return normalized;
}
function parseRealIp(realIp?: string): string | undefined {
const raw = realIp?.trim();
if (!raw) {
return parseIpLiteral(realIp);
}
function resolveForwardedClientIp(params: {
forwardedFor?: string;
trustedProxies?: string[];
}): string | undefined {
const { forwardedFor, trustedProxies } = params;
if (!trustedProxies?.length) {
return undefined;
}
return normalizeIp(stripOptionalPort(raw));
const forwardedChain: string[] = [];
for (const entry of forwardedFor?.split(",") ?? []) {
const normalized = parseIpLiteral(entry);
if (normalized) {
forwardedChain.push(normalized);
}
}
if (forwardedChain.length === 0) {
return undefined;
}
// Walk right-to-left and return the first untrusted hop.
for (let index = forwardedChain.length - 1; index >= 0; index -= 1) {
const hop = forwardedChain[index];
if (!isTrustedProxyAddress(hop, trustedProxies)) {
return hop;
}
}
return undefined;
}
/**
@@ -252,11 +258,13 @@ export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: s
});
}
export function resolveGatewayClientIp(params: {
export function resolveClientIp(params: {
remoteAddr?: string;
forwardedFor?: string;
realIp?: string;
trustedProxies?: string[];
/** Default false: only trust X-Real-IP when explicitly enabled. */
allowRealIpFallback?: boolean;
}): string | undefined {
const remote = normalizeIp(params.remoteAddr);
if (!remote) {
@@ -268,10 +276,17 @@ export function resolveGatewayClientIp(params: {
// Fail closed when traffic comes from a trusted proxy but client-origin headers
// are missing or invalid. Falling back to the proxy's own IP can accidentally
// treat unrelated requests as local/trusted.
return (
parseForwardedForClientIp(params.forwardedFor, params.trustedProxies) ??
parseRealIp(params.realIp)
);
const forwardedIp = resolveForwardedClientIp({
forwardedFor: params.forwardedFor,
trustedProxies: params.trustedProxies,
});
if (forwardedIp) {
return forwardedIp;
}
if (params.allowRealIpFallback) {
return parseRealIp(params.realIp);
}
return undefined;
}
export function isLocalGatewayAddress(ip: string | undefined): boolean {

View File

@@ -20,6 +20,7 @@ type OpenAiHttpOptions = {
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
};
@@ -162,6 +163,7 @@ export async function handleOpenAiHttpRequest(
pathname: "/v1/chat/completions",
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
maxBodyBytes: opts.maxBodyBytes ?? 1024 * 1024,
});

View File

@@ -55,6 +55,7 @@ type OpenResponsesHttpOptions = {
maxBodyBytes?: number;
config?: GatewayHttpResponsesConfig;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
};
@@ -343,6 +344,7 @@ export async function handleOpenResponsesHttpRequest(
pathname: "/v1/responses",
auth: opts.auth,
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
maxBodyBytes,
});

View File

@@ -133,17 +133,26 @@ async function authorizeCanvasRequest(params: {
req: IncomingMessage;
auth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
clients: Set<GatewayWsClient>;
canvasCapability?: string;
malformedScopedPath?: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<GatewayAuthResult> {
const { req, auth, trustedProxies, clients, canvasCapability, malformedScopedPath, rateLimiter } =
params;
const {
req,
auth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability,
malformedScopedPath,
rateLimiter,
} = params;
if (malformedScopedPath) {
return { ok: false, reason: "unauthorized" };
}
if (isLocalDirectRequest(req, trustedProxies)) {
if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) {
return { ok: true };
}
@@ -155,6 +164,7 @@ async function authorizeCanvasRequest(params: {
connectAuth: { token, password: token },
req,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (authResult.ok) {
@@ -497,6 +507,7 @@ export function createGatewayHttpServer(opts: {
try {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
if (scopedCanvas.malformedScopedPath) {
sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" });
@@ -513,6 +524,7 @@ export function createGatewayHttpServer(opts: {
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
})
) {
@@ -532,6 +544,7 @@ export function createGatewayHttpServer(opts: {
connectAuth: token ? { token, password: token } : null,
req,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (!authResult.ok) {
@@ -549,6 +562,7 @@ export function createGatewayHttpServer(opts: {
auth: resolvedAuth,
config: openResponsesConfig,
trustedProxies,
allowRealIpFallback,
rateLimiter,
})
) {
@@ -560,6 +574,7 @@ export function createGatewayHttpServer(opts: {
await handleOpenAiHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
})
) {
@@ -572,6 +587,7 @@ export function createGatewayHttpServer(opts: {
req,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability: scopedCanvas.capability,
malformedScopedPath: scopedCanvas.malformedScopedPath,
@@ -648,10 +664,12 @@ export function attachGatewayUpgradeHandler(opts: {
if (url.pathname === CANVAS_WS_PATH) {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const ok = await authorizeCanvasRequest({
req,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability: scopedCanvas.capability,
malformedScopedPath: scopedCanvas.malformedScopedPath,

View File

@@ -41,7 +41,7 @@ import {
mintCanvasCapabilityToken,
} from "../../canvas-capability.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js";
import { resolveHostName } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
@@ -176,7 +176,14 @@ export function attachGatewayWsMessageHandler(params: {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const clientIp = resolveClientIp({
remoteAddr,
forwardedFor,
realIp,
trustedProxies,
allowRealIpFallback,
});
// If proxy headers are present but the remote address isn't trusted, don't treat
// the connection as local. This prevents auth bypass when running behind a reverse
@@ -189,7 +196,7 @@ export function attachGatewayWsMessageHandler(params: {
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
const reportedClientIp =
isLocalClient || hasUntrustedProxyHeaders
? undefined
@@ -389,6 +396,7 @@ export function attachGatewayWsMessageHandler(params: {
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter,
clientIp,
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
@@ -424,6 +432,7 @@ export function attachGatewayWsMessageHandler(params: {
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
// Shared-auth probe only; rate-limit side effects are handled in
// the primary auth flow (or deferred for device-token candidates).
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,

View File

@@ -131,6 +131,7 @@ export async function handleToolsInvokeHttpRequest(
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
},
): Promise<boolean> {
@@ -151,6 +152,7 @@ export async function handleToolsInvokeHttpRequest(
connectAuth: token ? { token, password: token } : null,
req,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
});
if (!authResult.ok) {