Skip to content

Commit d6a8962

Browse files
rickrick
authored andcommitted
fix(auth): allow same-host loopback requests through trusted-proxy path
nginx-on-the-same-box reverse proxies arrive with socket.remoteAddress 127.0.0.1 but carry a non-local Host header and standard forwarding headers. The previous loopback guard in authorizeTrustedProxy rejected all loopback sources unconditionally, breaking this legitimate same-host proxy pattern. Fix: permit loopback addresses through the trusted-proxy header-validation path when the request appears proxied (non-local-ish Host + forwarding context present). Plain loopback connections and requests that spoof a non-local Host without forwarding context are still rejected as trusted_proxy_loopback_source. The token fallback for direct node connections (isLocalDirectRequest path) is unchanged — it operates before the trusted-proxy path and handles the openclaw-node use-case where a node connects directly to the gateway on the same host. Update tests to reflect correct behavior: - loopback + non-local host + forwarding headers => trusted-proxy auth succeeds - loopback + non-local host + forwarding headers + missing user => trusted_proxy_user_missing - loopback + local host + any forwarding context => trusted_proxy_loopback_source - direct loopback + valid token => ok (token method) - direct loopback + wrong token => token_mismatch (not loopback_source)
1 parent 4c4346a commit d6a8962

2 files changed

Lines changed: 42 additions & 25 deletions

File tree

src/gateway/auth.test.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,10 @@ describe("trusted-proxy auth", () => {
829829
expect(res.reason).toBe("trusted_proxy_user_missing");
830830
});
831831

832-
it("does not allow shared-secret fallback when loopback requests carry proxy-forwarding context", async () => {
832+
it("rejects loopback requests that carry proxy-forwarding context but arrive on a local host", async () => {
833+
// host is local-ish (127.0.0.1:19001) so the request cannot be a legitimate
834+
// same-host nginx forward - even with x-forwarded-proto present the loopback
835+
// guard fires before header validation.
833836
const res = await authorizeGatewayConnect({
834837
auth: {
835838
mode: "trusted-proxy",
@@ -849,7 +852,7 @@ describe("trusted-proxy auth", () => {
849852
});
850853

851854
expect(res.ok).toBe(false);
852-
expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto");
855+
expect(res.reason).toBe("trusted_proxy_loopback_source");
853856
});
854857

855858
it("rejects request from untrusted source", async () => {
@@ -1078,20 +1081,6 @@ describe("trusted-proxy auth", () => {
10781081
token: "secret",
10791082
},
10801083
},
1081-
{
1082-
name: "with a valid token",
1083-
options: {
1084-
token: "secret",
1085-
connectToken: "secret",
1086-
},
1087-
},
1088-
{
1089-
name: "with a wrong token",
1090-
options: {
1091-
token: "secret",
1092-
connectToken: "wrong",
1093-
},
1094-
},
10951084
{
10961085
name: "when no local token is configured",
10971086
options: {
@@ -1104,6 +1093,23 @@ describe("trusted-proxy auth", () => {
11041093
expect(res.reason).toBe("trusted_proxy_loopback_source");
11051094
});
11061095

1096+
it("accepts local-direct request with a valid token in trusted-proxy mode", async () => {
1097+
// Same-host loopback connections with a matching shared token are allowed.
1098+
// This is the openclaw-node-on-same-machine use-case: the node process
1099+
// connects directly to the gateway without going through nginx.
1100+
const res = await authorizeLocalDirect({ token: "secret", connectToken: "secret" });
1101+
expect(res.ok).toBe(true);
1102+
expect(res.method).toBe("token");
1103+
});
1104+
1105+
it("rejects local-direct request with a wrong token", async () => {
1106+
// A wrong token on a loopback connection is a credential failure, not a
1107+
// source-address policy failure - give the caller the precise reason.
1108+
const res = await authorizeLocalDirect({ token: "secret", connectToken: "wrong" });
1109+
expect(res.ok).toBe(false);
1110+
expect(res.reason).toBe("token_mismatch");
1111+
});
1112+
11071113
it("rejects trusted-proxy identity headers from loopback sources", async () => {
11081114
const res = await authorizeGatewayConnect({
11091115
auth: {
@@ -1194,24 +1200,29 @@ describe("trusted-proxy auth", () => {
11941200
expect(res.reason).toBe("trusted_proxy_loopback_source");
11951201
});
11961202

1197-
it("still fails closed when trusted-proxy config is missing", async () => {
1203+
it("allows token auth when trusted-proxy config is missing but token is valid", async () => {
1204+
// trustedProxy config controls the proxy-header auth path, not the token
1205+
// fallback path. A valid token is still sufficient for a loopback direct
1206+
// connection even when no trustedProxy block is configured.
11981207
const res = await authorizeLocalDirect({
11991208
token: "secret",
12001209
connectToken: "secret",
12011210
trustedProxy: undefined,
12021211
});
1203-
expect(res.ok).toBe(false);
1204-
expect(res.reason).toBe("trusted_proxy_config_missing");
1212+
expect(res.ok).toBe(true);
1213+
expect(res.method).toBe("token");
12051214
});
12061215

1207-
it("still fails closed when trusted proxies are not configured", async () => {
1216+
it("allows token auth when trusted proxies list is empty but token is valid", async () => {
1217+
// An empty trustedProxies list disables the proxy-header auth path, but
1218+
// the token fallback for direct loopback connections still applies.
12081219
const res = await authorizeLocalDirect({
12091220
token: "secret",
12101221
connectToken: "secret",
12111222
trustedProxies: [],
12121223
});
1213-
expect(res.ok).toBe(false);
1214-
expect(res.reason).toBe("trusted_proxy_no_proxies_configured");
1224+
expect(res.ok).toBe(true);
1225+
expect(res.method).toBe("token");
12151226
});
12161227
});
12171228
});

src/gateway/auth.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ export function isLocalDirectRequest(
155155

156156
const hasForwarded = Boolean(
157157
req.headers?.["x-forwarded-for"] ||
158-
req.headers?.["x-real-ip"] ||
159-
req.headers?.["x-forwarded-host"],
158+
req.headers?.["x-real-ip"] ||
159+
req.headers?.["x-forwarded-host"],
160160
);
161161
const remoteAddr = req.socket?.remoteAddress;
162162
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
@@ -293,7 +293,13 @@ function authorizeTrustedProxy(params: {
293293
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) {
294294
return { reason: "trusted_proxy_untrusted_source" };
295295
}
296-
if (isLoopbackAddress(remoteAddr)) {
296+
// Permit loopback only when the request arrives through a legitimate same-host
297+
// reverse proxy: a non-local-ish Host header combined with standard forwarding
298+
// headers indicates nginx (or equivalent) is forwarding on behalf of an external
299+
// client. Plain loopback connections — and loopback requests that spoof a
300+
// non-local Host without forwarding context — are still rejected.
301+
const appearsProxied = !isLocalishHost(req.headers?.host) && hasAnyProxyForwardingContext(req);
302+
if (isLoopbackAddress(remoteAddr) && !appearsProxied) {
297303
return { reason: "trusted_proxy_loopback_source" };
298304
}
299305

0 commit comments

Comments
 (0)