From 094dbdaf2be7ef0e16afc256fba014a651bbf0fc Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:03:53 +0000 Subject: [PATCH] fix(gateway): require loopback proxy IP for trusted-proxy + bind=loopback (#22082) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 6ff3ca9b5db530c2ea4abbd027ee98a9c4a1be67 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs/gateway/trusted-proxy-auth.md | 7 +++- src/gateway/server-runtime-config.test.ts | 45 +++++++++++++++++++++++ src/gateway/server-runtime-config.ts | 17 ++++++++- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8adb97429..93a3336cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky. - Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu. - Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. - Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index 018af7597..f9debcfae 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -39,8 +39,8 @@ Use `trusted-proxy` auth mode when: ```json5 { gateway: { - // Must bind to network interface (not loopback) - bind: "lan", + // Use loopback for same-host proxy setups; use lan/custom for remote proxy hosts + bind: "loopback", // CRITICAL: Only add your proxy's IP(s) here trustedProxies: ["10.0.0.1", "172.17.0.1"], @@ -62,6 +62,9 @@ Use `trusted-proxy` auth mode when: } ``` +If `gateway.bind` is `loopback`, include a loopback proxy address in +`gateway.trustedProxies` (`127.0.0.1`, `::1`, or an equivalent loopback CIDR). + ### Configuration Reference | Field | Required | Description | diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 933e55e80..929be45b4 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -51,6 +51,27 @@ describe("resolveGatewayRuntimeConfig", () => { expect(result.bindHost).toBe("127.0.0.1"); }); + it("should allow loopback trusted-proxy when trustedProxies includes ::1", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["::1"], + }, + }; + + const result = await resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }); + expect(result.bindHost).toBe("127.0.0.1"); + }); + it("should reject loopback trusted-proxy without trustedProxies configured", async () => { const cfg = { gateway: { @@ -75,6 +96,30 @@ describe("resolveGatewayRuntimeConfig", () => { ); }); + it("should reject loopback trusted-proxy when trustedProxies has no loopback address", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow( + "gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR", + ); + }); + it("should reject trusted-proxy without trustedProxies configured", async () => { const cfg = { gateway: { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 85a595bbb..e651801db 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -11,7 +11,12 @@ import { } from "./auth.js"; import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; -import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js"; +import { + isLoopbackHost, + isTrustedProxyAddress, + isValidIPv4, + resolveGatewayBindHost, +} from "./net.js"; import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { @@ -122,6 +127,16 @@ export async function resolveGatewayRuntimeConfig(params: { "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP", ); } + if (isLoopbackHost(bindHost)) { + const hasLoopbackTrustedProxy = + isTrustedProxyAddress("127.0.0.1", trustedProxies) || + isTrustedProxyAddress("::1", trustedProxies); + if (!hasLoopbackTrustedProxy) { + throw new Error( + "gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR", + ); + } + } } return {