Skip to content

Gateway: add trustedProxy.loopbackUser for loopback auth#42388

Closed
lynnzc wants to merge 1 commit intoopenclaw:mainfrom
lynnzc:codex/feature-issue-26007-20260310171232
Closed

Gateway: add trustedProxy.loopbackUser for loopback auth#42388
lynnzc wants to merge 1 commit intoopenclaw:mainfrom
lynnzc:codex/feature-issue-26007-20260310171232

Conversation

@lynnzc
Copy link
Copy Markdown
Contributor

@lynnzc lynnzc commented Mar 10, 2026

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: In gateway.auth.mode: trusted-proxy, loopback CLI/sub-agent requests are rejected when they cannot provide the configured userHeader.
  • Why it matters: Local CLI/sub-agent flows (including spawn/session workflows) break even when the gateway is running locally behind a reverse proxy.
  • What changed: Added optional gateway.auth.trustedProxy.loopbackUser and used it as a fallback identity only when the request is from a trusted loopback address and userHeader is missing.
  • What did NOT change (scope boundary): No fallback for non-loopback sources; token/password auth flows and trusted-proxy allowlist semantics remain unchanged.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • New optional config: gateway.auth.trustedProxy.loopbackUser.
  • In trusted-proxy mode, if a request comes from a trusted loopback IP (127.0.0.1/::1) and userHeader is missing, gateway now authenticates as loopbackUser.
  • allowUsers still applies to the resolved identity (including loopbackUser).

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22+, pnpm
  • Model/provider: N/A
  • Integration/channel (if any): Gateway auth
  • Relevant config (redacted): gateway.auth.mode=trusted-proxy, gateway.trustedProxies includes loopback

Steps

  1. Configure gateway.auth.mode: "trusted-proxy" and gateway.auth.trustedProxy.userHeader.
  2. Send a local loopback request without the user header.
  3. Set gateway.auth.trustedProxy.loopbackUser and retry.

Expected

  • Step 2: rejected (trusted_proxy_user_missing).
  • Step 3: accepted as loopbackUser when source is trusted loopback.

Actual

  • Matches expected; covered by new tests.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Test command and result:

  • pnpm test src/gateway/auth.test.ts src/config/schema.test.ts → passed (2 files, 62 tests)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: loopback fallback success, non-loopback still rejected without header, allowlist enforced on loopback fallback.
  • Edge cases checked: IPv4 loopback and IPv6 loopback paths in tests.
  • What you did not verify: full end-to-end gateway runtime with live reverse proxy.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

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

  • Backward compatible? (Yes)
  • Config/env changes? (Yes)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: remove gateway.auth.trustedProxy.loopbackUser (or revert this commit).
  • Files/config to restore: gateway auth config (gateway.auth.trustedProxy).
  • Known bad symptoms reviewers should watch for: unexpected loopback auth acceptance if loopbackUser is set intentionally.

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: Operator misconfiguration of loopbackUser identity string.
    • Mitigation: Fallback is restricted to trusted loopback sources; allowUsers still gates access.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 10, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Trusted-proxy auth bypass via loopbackUser fallback for same-host proxies

1. 🟠 Trusted-proxy auth bypass via loopbackUser fallback for same-host proxies

Property Value
Severity High
CWE CWE-287
Location src/gateway/auth.ts:359-370

Description

authorizeTrustedProxy now supports trustedProxy.loopbackUser as a fallback identity when the configured userHeader is missing.

This introduces an authentication bypass risk in common same-host reverse proxy deployments:

  • In trusted-proxy mode, the gateway trusts requests based on the TCP peer IP (req.socket.remoteAddress) being in gateway.trustedProxies.
  • For same-host proxies (Caddy/nginx on the same machine), remoteAddress is typically 127.0.0.1/::1 for all proxied traffic, including requests originating from remote clients.
  • With loopbackUser set, any proxied request that reaches the gateway without the user header (due to proxy misconfiguration, a non-authenticated route, header stripping bugs, or forward-auth failures) will be treated as authenticated as loopbackUser.
  • This effectively turns “missing identity header” from a fail-closed condition into a fail-open condition whenever the trusted proxy hop is loopback.

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.

Recommendation

Make loopbackUser apply only to direct local requests, not to requests forwarded by a reverse proxy, by basing the decision on the resolved client IP (from X-Forwarded-For / X-Real-IP) and/or absence of forwarded headers.

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):

  • Emit a startup/security-audit warning when loopbackUser is set and allowUsers is empty (implicit allow-all).
  • Document loopbackUser in docs/gateway/trusted-proxy-auth.md and configuration-reference.md with an explicit warning: do not rely on it for requests that traverse a proxy; intended for local direct CLI traffic only.

Analyzed PR: #42388 at commit c59e0bc

Last updated on: 2026-03-10T17:38:52Z

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: S labels Mar 10, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/gateway/auth.ts
Comment on lines +365 to +366
: isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0
? loopbackUser
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 10, 2026

Greptile Summary

This PR introduces an optional gateway.auth.trustedProxy.loopbackUser config field as a fallback user identity in trusted-proxy mode when requests arrive from trusted loopback addresses without the configured userHeader. The feature enables local CLI/sub-agent workflows to authenticate even when they cannot set proxy-injected headers.

Key findings:

  • The loopback guard (isLoopbackAddress(remoteAddr) on line 365) does not differentiate between direct CLI connections and requests proxy-forwarded through a localhost reverse proxy. In a deployment where the reverse proxy itself runs on loopback (e.g., Nginx on 127.0.0.1), proxy-forwarded requests that lack the user header will silently authenticate as loopbackUser, defeating the intended boundary between direct and proxied requests. This should be addressed by adding a forwarding-header check (e.g., via isLocalDirectRequest or a lighter check for presence of proxy headers).
  • Test coverage is missing for the loopback-proxy-forwarded scenario (loopback source IP with x-forwarded-for header and no user header), which is the critical boundary that determines whether the feature remains scoped to truly direct connections. A test case should be added to explicitly define and enforce this boundary.
  • Type, schema, and basic test coverage are correct; the happy-path, non-loopback rejection, and allowUsers enforcement are all properly tested.

Confidence Score: 2/5

  • Do not merge until the loopback-proxy-forwarded security boundary is addressed.
  • The PR introduces a real security boundary gap: the loopback fallback does not distinguish direct CLI requests from proxy-forwarded requests through a localhost reverse proxy. If a trusted proxy running on loopback sends requests without proper user headers (due to misconfiguration or compromise), those requests will authenticate as loopbackUser. While the feature itself is well-scoped and backward-compatible, the implementation of the loopback guard is too broad for deployments where the proxy runs on the same host. This gap should be closed before merging by either (1) adding a forwarding-header check to the guard, or (2) explicitly documenting and testing this behavior if it is intentional.
  • src/gateway/auth.ts — loopback guard needs to check for proxy forwarding headers; src/gateway/auth.test.ts — missing test for loopback-proxy-forwarded scenario.

Last reviewed commit: c59e0bc

Comment thread src/gateway/auth.ts
Comment on lines +362 to +367
const user =
userFromHeader && userFromHeader.length > 0
? userFromHeader
: isLoopbackAddress(remoteAddr) && loopbackUser && loopbackUser.length > 0
? loopbackUser
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment thread src/gateway/auth.test.ts
Comment on lines +535 to +589
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");
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: trustedProxy.loopbackUser for CLI/sub-agent access without proxy

1 participant