From 9c5249714db4b88180c83ba937baf1c84dbf3cf0 Mon Sep 17 00:00:00 2001 From: Xinhua Gu Date: Fri, 20 Feb 2026 18:51:35 +0100 Subject: [PATCH] fix(gateway): trusted-proxy auth rejected when bind=loopback (#20097) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 8de62f1a8f991f900fd1482f64976f234011f4d2 Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/commands/configure.gateway.e2e.test.ts | 6 ++--- src/commands/configure.gateway.ts | 6 ++--- src/gateway/server-runtime-config.test.ts | 29 +++++++++++++++++++--- src/gateway/server-runtime-config.ts | 5 ---- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005197e1046..8adb974290e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. diff --git a/src/commands/configure.gateway.e2e.test.ts b/src/commands/configure.gateway.e2e.test.ts index a4d78463256..05f634d85fe 100644 --- a/src/commands/configure.gateway.e2e.test.ts +++ b/src/commands/configure.gateway.e2e.test.ts @@ -127,7 +127,7 @@ describe("promptGatewayConfig", () => { requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], allowUsers: ["nick@example.com"], }); - expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.bind).toBe("loopback"); expect(result.config.gateway?.trustedProxies).toEqual(["10.0.1.10", "192.168.1.5"]); }); @@ -141,7 +141,7 @@ describe("promptGatewayConfig", () => { userHeader: "x-remote-user", // requiredHeaders and allowUsers should be undefined when empty }); - expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.bind).toBe("loopback"); expect(result.config.gateway?.trustedProxies).toEqual(["10.0.0.1"]); }); @@ -150,7 +150,7 @@ describe("promptGatewayConfig", () => { tailscaleMode: "serve", textQueue: ["18789", "x-forwarded-user", "", "", "10.0.0.1"], }); - expect(result.config.gateway?.bind).toBe("lan"); + expect(result.config.gateway?.bind).toBe("loopback"); expect(result.config.gateway?.tailscale?.mode).toBe("off"); expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false); }); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 2ce2c605b21..ec9a2970e2c 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -142,10 +142,8 @@ export async function promptGatewayConfig( authMode = "password"; } - if (authMode === "trusted-proxy" && bind === "loopback") { - note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note"); - bind = "lan"; - } + // trusted-proxy + loopback is valid when the reverse proxy runs on the same + // host (e.g. cloudflared, nginx, Caddy). trustedProxies must include 127.0.0.1. if (authMode === "trusted-proxy" && tailscaleMode !== "off") { note( "Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.", diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 360ad6f9ec0..933e55e8041 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -30,7 +30,7 @@ describe("resolveGatewayRuntimeConfig", () => { expect(result.bindHost).toBe("0.0.0.0"); }); - it("should reject loopback binding with trusted-proxy auth mode", async () => { + it("should allow loopback binding with trusted-proxy auth mode", async () => { const cfg = { gateway: { bind: "loopback" as const, @@ -40,7 +40,28 @@ describe("resolveGatewayRuntimeConfig", () => { userHeader: "x-forwarded-user", }, }, - trustedProxies: ["192.168.1.1"], + trustedProxies: ["127.0.0.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: { + bind: "loopback" as const, + auth: { + mode: "trusted-proxy" as const, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: [], }, }; @@ -49,7 +70,9 @@ describe("resolveGatewayRuntimeConfig", () => { cfg, port: 18789, }), - ).rejects.toThrow("gateway auth mode=trusted-proxy makes no sense with bind=loopback"); + ).rejects.toThrow( + "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", + ); }); it("should reject trusted-proxy without trustedProxies configured", async () => { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 896faf4dada..85a595bbb0e 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -117,11 +117,6 @@ export async function resolveGatewayRuntimeConfig(params: { } if (authMode === "trusted-proxy") { - if (isLoopbackHost(bindHost)) { - throw new Error( - "gateway auth mode=trusted-proxy makes no sense with bind=loopback; use bind=lan or bind=custom with gateway.trustedProxies configured", - ); - } if (trustedProxies.length === 0) { throw new Error( "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",