Gateway: add trustedProxy.loopbackUser for loopback auth#42388
Gateway: add trustedProxy.loopbackUser for loopback auth#42388lynnzc wants to merge 1 commit intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟠 Trusted-proxy auth bypass via loopbackUser fallback for same-host proxies
Description
This introduces an authentication bypass risk in common same-host reverse proxy deployments:
Vulnerable logic: const userFromHeader = userHeaderValue?.trim();
const loopbackUser = trustedProxyConfig.loopbackUser?.trim();
const user =
userFromHeader && userFromHeader.length > 0
? userFromHeader
: isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0
? loopbackUser
: undefined;Impact depends on configuration, but can be severe because trusted-proxy mode controls gateway operator access. RecommendationMake Minimal defense-in-depth change (fail closed for proxied traffic): const remoteAddr = req.socket?.remoteAddress;
const forwardedFor = headerValue(req.headers["x-forwarded-for"]);
const realIp = headerValue(req.headers["x-real-ip"]);
const clientIp = resolveClientIp({
remoteAddr,
forwardedFor,
realIp,
trustedProxies,
allowRealIpFallback: false,
});
const isDirectLoopback = isLoopbackAddress(clientIp) && !forwardedFor && !realIp;
const user = userFromHeader?.trim()
? userFromHeader.trim()
: isDirectLoopback && loopbackUser?.trim()
? loopbackUser.trim()
: undefined;Additional low-cost hardening (project-pattern-aligned):
Analyzed PR: #42388 at commit Last updated on: 2026-03-10T17:38:52Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c59e0bc92d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| : isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0 | ||
| ? loopbackUser |
There was a problem hiding this comment.
Restrict loopback fallback to direct local requests
Using isLoopbackAddress(remoteAddr) alone makes loopbackUser apply to any request that arrives from a loopback proxy, including remote users forwarded through a same-host reverse proxy when userHeader is absent. In trusted-proxy deployments where the gateway sits behind 127.0.0.1/::1, this changes a previously hard failure (trusted_proxy_user_missing) into successful auth as loopbackUser, which can grant unintended access if any proxy route omits that header.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR introduces an optional Key findings:
Confidence Score: 2/5
Last reviewed commit: c59e0bc |
| const user = | ||
| userFromHeader && userFromHeader.length > 0 | ||
| ? userFromHeader | ||
| : isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0 | ||
| ? loopbackUser | ||
| : undefined; |
There was a problem hiding this comment.
loopbackUser fallback triggers on loopback-proxy-forwarded requests
isLoopbackAddress(remoteAddr) only checks that the TCP socket originates from a loopback IP — it does not verify the connection is a direct (non-proxy-forwarded) request. In a common deployment where the trusted reverse proxy itself runs on the same host (e.g., Nginx/Caddy/Traefik on 127.0.0.1), the proxy's forwarded requests also arrive with remoteAddr === "127.0.0.1".
This means any proxy-forwarded request that lacks the user header (e.g., a health-check route that doesn't pass through the identity-aware portion of the proxy, or a misconfigured proxy rule) will silently authenticate as loopbackUser, even though it was never a "direct CLI/sub-agent" request.
The existing isLocalDirectRequest helper already solves this by additionally checking that no forwarding headers (x-forwarded-for, x-real-ip, x-forwarded-host) are present on the request, which distinguishes a direct loopback connection from a proxy-forwarded one:
| const user = | |
| userFromHeader && userFromHeader.length > 0 | |
| ? userFromHeader | |
| : isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0 | |
| ? loopbackUser | |
| : undefined; | |
| : isLocalDirectRequest(req, trustedProxies) && loopbackUser && loopbackUser.length > 0 |
This would restrict the fallback to truly direct connections that do not carry proxy forwarding headers, matching the described intent ("direct loopback requests … for local CLI/sub-agent traffic").
Note: isLocalDirectRequest also checks isLocalishHost, so you'd need to validate whether that constraint is acceptable; alternatively, a lighter isLoopbackAddress(remoteAddr) && !hasForwardedHeaders(req) guard would achieve the same forwarding-header check without the host constraint.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/auth.ts
Line: 362-367
Comment:
`loopbackUser` fallback triggers on loopback-proxy-forwarded requests
`isLoopbackAddress(remoteAddr)` only checks that the TCP socket originates from a loopback IP — it does not verify the connection is a *direct* (non-proxy-forwarded) request. In a common deployment where the trusted reverse proxy itself runs on the same host (e.g., Nginx/Caddy/Traefik on `127.0.0.1`), the proxy's forwarded requests also arrive with `remoteAddr === "127.0.0.1"`.
This means any proxy-forwarded request that lacks the user header (e.g., a health-check route that doesn't pass through the identity-aware portion of the proxy, or a misconfigured proxy rule) will silently authenticate as `loopbackUser`, even though it was never a "direct CLI/sub-agent" request.
The existing `isLocalDirectRequest` helper already solves this by additionally checking that no forwarding headers (`x-forwarded-for`, `x-real-ip`, `x-forwarded-host`) are present on the request, which distinguishes a direct loopback connection from a proxy-forwarded one:
```suggestion
: isLocalDirectRequest(req, trustedProxies) && loopbackUser && loopbackUser.length > 0
```
This would restrict the fallback to truly direct connections that do not carry proxy forwarding headers, matching the described intent ("direct loopback requests … for local CLI/sub-agent traffic").
Note: `isLocalDirectRequest` also checks `isLocalishHost`, so you'd need to validate whether that constraint is acceptable; alternatively, a lighter `isLoopbackAddress(remoteAddr) && !hasForwardedHeaders(req)` guard would achieve the same forwarding-header check without the host constraint.
How can I resolve this? If you propose a fix, please make it concise.| it("accepts loopbackUser when user header is missing on loopback", async () => { | ||
| const res = await authorizeTrustedProxy({ | ||
| auth: { | ||
| mode: "trusted-proxy", | ||
| allowTailscale: false, | ||
| trustedProxy: { | ||
| userHeader: "x-forwarded-user", | ||
| loopbackUser: "local@example.com", | ||
| }, | ||
| }, | ||
| trustedProxies: ["127.0.0.1"], | ||
| remoteAddress: "127.0.0.1", | ||
| }); | ||
|
|
||
| expect(res.ok).toBe(true); | ||
| expect(res.method).toBe("trusted-proxy"); | ||
| expect(res.user).toBe("local@example.com"); | ||
| }); | ||
|
|
||
| it("rejects missing user header from non-loopback even when loopbackUser is set", async () => { | ||
| const res = await authorizeTrustedProxy({ | ||
| auth: { | ||
| mode: "trusted-proxy", | ||
| allowTailscale: false, | ||
| trustedProxy: { | ||
| userHeader: "x-forwarded-user", | ||
| loopbackUser: "local@example.com", | ||
| }, | ||
| }, | ||
| trustedProxies: ["10.0.0.1"], | ||
| remoteAddress: "10.0.0.1", | ||
| }); | ||
|
|
||
| expect(res.ok).toBe(false); | ||
| expect(res.reason).toBe("trusted_proxy_user_missing"); | ||
| }); | ||
|
|
||
| it("applies allowUsers to loopbackUser fallback", async () => { | ||
| const res = await authorizeTrustedProxy({ | ||
| auth: { | ||
| mode: "trusted-proxy", | ||
| allowTailscale: false, | ||
| trustedProxy: { | ||
| userHeader: "x-forwarded-user", | ||
| loopbackUser: "local@example.com", | ||
| allowUsers: ["admin@example.com"], | ||
| }, | ||
| }, | ||
| trustedProxies: ["::1"], | ||
| remoteAddress: "::1", | ||
| }); | ||
|
|
||
| expect(res.ok).toBe(false); | ||
| expect(res.reason).toBe("trusted_proxy_user_not_allowed"); | ||
| }); |
There was a problem hiding this comment.
Missing test: loopback-proxy-forwarded request with loopbackUser set
The new tests cover the intended happy path and the non-loopback rejection, but there is no test for the case where the request arrives from a loopback IP with proxy forwarding headers (e.g., x-forwarded-for present). This is the "proxy is on localhost" scenario and it directly affects whether the fallback is constrained to truly direct connections.
A test like the following would pin the intended behavior and make the distinction explicit:
| it("accepts loopbackUser when user header is missing on loopback", async () => { | |
| const res = await authorizeTrustedProxy({ | |
| auth: { | |
| mode: "trusted-proxy", | |
| allowTailscale: false, | |
| trustedProxy: { | |
| userHeader: "x-forwarded-user", | |
| loopbackUser: "local@example.com", | |
| }, | |
| }, | |
| trustedProxies: ["127.0.0.1"], | |
| remoteAddress: "127.0.0.1", | |
| }); | |
| expect(res.ok).toBe(true); | |
| expect(res.method).toBe("trusted-proxy"); | |
| expect(res.user).toBe("local@example.com"); | |
| }); | |
| it("rejects missing user header from non-loopback even when loopbackUser is set", async () => { | |
| const res = await authorizeTrustedProxy({ | |
| auth: { | |
| mode: "trusted-proxy", | |
| allowTailscale: false, | |
| trustedProxy: { | |
| userHeader: "x-forwarded-user", | |
| loopbackUser: "local@example.com", | |
| }, | |
| }, | |
| trustedProxies: ["10.0.0.1"], | |
| remoteAddress: "10.0.0.1", | |
| }); | |
| expect(res.ok).toBe(false); | |
| expect(res.reason).toBe("trusted_proxy_user_missing"); | |
| }); | |
| it("applies allowUsers to loopbackUser fallback", async () => { | |
| const res = await authorizeTrustedProxy({ | |
| auth: { | |
| mode: "trusted-proxy", | |
| allowTailscale: false, | |
| trustedProxy: { | |
| userHeader: "x-forwarded-user", | |
| loopbackUser: "local@example.com", | |
| allowUsers: ["admin@example.com"], | |
| }, | |
| }, | |
| trustedProxies: ["::1"], | |
| remoteAddress: "::1", | |
| }); | |
| expect(res.ok).toBe(false); | |
| expect(res.reason).toBe("trusted_proxy_user_not_allowed"); | |
| }); | |
| it("does not apply loopbackUser when request is proxy-forwarded through loopback", async () => { | |
| const res = await authorizeTrustedProxy({ | |
| auth: { | |
| mode: "trusted-proxy", | |
| allowTailscale: false, | |
| trustedProxy: { | |
| userHeader: "x-forwarded-user", | |
| loopbackUser: "local@example.com", | |
| }, | |
| }, | |
| trustedProxies: ["127.0.0.1"], | |
| remoteAddress: "127.0.0.1", | |
| headers: { | |
| // proxy-forwarded: x-forwarded-for indicates it went through the proxy | |
| "x-forwarded-for": "203.0.113.5", | |
| }, | |
| }); | |
| expect(res.ok).toBe(false); | |
| expect(res.reason).toBe("trusted_proxy_user_missing"); | |
| }); |
Adding this test would clarify (and enforce) the boundary of the feature.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/auth.test.ts
Line: 535-589
Comment:
Missing test: loopback-proxy-forwarded request with `loopbackUser` set
The new tests cover the intended happy path and the non-loopback rejection, but there is no test for the case where the request arrives from a loopback IP *with* proxy forwarding headers (e.g., `x-forwarded-for` present). This is the "proxy is on localhost" scenario and it directly affects whether the fallback is constrained to truly direct connections.
A test like the following would pin the intended behavior and make the distinction explicit:
```suggestion
it("does not apply loopbackUser when request is proxy-forwarded through loopback", async () => {
const res = await authorizeTrustedProxy({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
loopbackUser: "local@example.com",
},
},
trustedProxies: ["127.0.0.1"],
remoteAddress: "127.0.0.1",
headers: {
// proxy-forwarded: x-forwarded-for indicates it went through the proxy
"x-forwarded-for": "203.0.113.5",
},
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_user_missing");
});
```
Adding this test would clarify (and enforce) the boundary of the feature.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Describe the problem and fix in 2–5 bullets:
gateway.auth.mode: trusted-proxy, loopback CLI/sub-agent requests are rejected when they cannot provide the configureduserHeader.gateway.auth.trustedProxy.loopbackUserand used it as a fallback identity only when the request is from a trusted loopback address anduserHeaderis missing.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
gateway.auth.trustedProxy.loopbackUser.trusted-proxymode, if a request comes from a trusted loopback IP (127.0.0.1/::1) anduserHeaderis missing, gateway now authenticates asloopbackUser.allowUsersstill applies to the resolved identity (includingloopbackUser).Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
gateway.auth.mode=trusted-proxy,gateway.trustedProxiesincludes loopbackSteps
gateway.auth.mode: "trusted-proxy"andgateway.auth.trustedProxy.userHeader.gateway.auth.trustedProxy.loopbackUserand retry.Expected
trusted_proxy_user_missing).loopbackUserwhen source is trusted loopback.Actual
Evidence
Attach at least one:
Test command and result:
pnpm test src/gateway/auth.test.ts src/config/schema.test.ts→ passed (2files,62tests)Human Verification (required)
What you personally verified (not just CI), and how:
Review Conversations
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
Compatibility / Migration
Yes)Yes)No)Failure Recovery (if this breaks)
gateway.auth.trustedProxy.loopbackUser(or revert this commit).gateway.auth.trustedProxy).loopbackUseris set intentionally.Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.loopbackUseridentity string.allowUsersstill gates access.