fix(gateway): allow trusted-proxy local-direct password fallback (#82953)

* fix(gateway): restore trusted-proxy local password fallback

* docs(changelog): note trusted-proxy password fallback fix

* docs(changelog): clarify trusted-proxy fallback policy
This commit is contained in:
Josh Avant
2026-05-17 01:35:59 -05:00
committed by GitHub
parent 8dc213227b
commit 7d99f8b021
8 changed files with 71 additions and 23 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.
- Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.
- Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)
- Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct `gateway.auth.password` fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.
- Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.
- Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.
- Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.

View File

@@ -42,6 +42,10 @@ Notes:
- When `gateway.auth.mode="trusted-proxy"`, the HTTP request must come from a
configured trusted proxy source; same-host loopback proxies require explicit
`gateway.auth.trustedProxy.allowLoopback = true`.
- Internal same-host callers that bypass the proxy can use
`gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` as a local direct
fallback. Any `Forwarded`, `X-Forwarded-*`, or `X-Real-IP` header evidence
keeps the request on the trusted-proxy path instead.
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
## Security boundary (important)

View File

@@ -23,6 +23,7 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
- use the matching Gateway HTTP auth path:
- shared-secret auth (`gateway.auth.mode="token"` or `"password"`): `Authorization: Bearer <token-or-password>`
- trusted-proxy auth (`gateway.auth.mode="trusted-proxy"`): identity-aware proxy headers from a configured trusted proxy source; same-host loopback proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`
- trusted-proxy local direct fallback: same-host callers with no `Forwarded`, `X-Forwarded-*`, or `X-Real-IP` headers can use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`
- private-ingress open auth (`gateway.auth.mode="none"`): no auth header
- treat the endpoint as full operator access for the gateway instance
- for shared-secret auth modes (`token` and `password`), ignore narrower bearer-declared `x-openclaw-scopes` values and restore the normal full operator defaults

View File

@@ -174,12 +174,11 @@ device id, so `nodes pending` does not show orphaned rows after a revoke.
Gateway pairing treats a connection as loopback only when both the raw socket
and any upstream proxy evidence agree. If a request arrives on loopback but
carries `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto` headers
that point at a non-local origin, that forwarded-header evidence disqualifies
the loopback locality claim. The pairing path then requires explicit approval
instead of silently treating the request as a same-host connect. See
[Trusted Proxy Auth](/gateway/trusted-proxy-auth) for the equivalent rule on
operator auth.
carries `Forwarded`, any `X-Forwarded-*`, or `X-Real-IP` header evidence, that
forwarded-header evidence disqualifies the loopback locality claim. The pairing
path then requires explicit approval instead of silently treating the request as
a same-host connect. See [Trusted Proxy Auth](/gateway/trusted-proxy-auth) for
the equivalent rule on operator auth.
## Storage (local, private)

View File

@@ -34,6 +34,10 @@ Notes:
- When `gateway.auth.mode="trusted-proxy"`, the HTTP request must come from a
configured trusted proxy source; same-host loopback proxies require explicit
`gateway.auth.trustedProxy.allowLoopback = true`.
- Internal same-host callers that bypass the proxy can use
`gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` as a local direct
fallback. Any `Forwarded`, `X-Forwarded-*`, or `X-Real-IP` header evidence
keeps the request on the trusted-proxy path instead.
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
## Security boundary (important)

View File

@@ -98,7 +98,7 @@ Implications:
- `allowLoopback` trusts local processes on the Gateway host to the same degree as the reverse proxy. Enable it only when the Gateway is still firewalled from direct remote access and the local proxy strips or overwrites client-supplied identity headers.
- Internal Gateway clients that do not travel through the reverse proxy should use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, not trusted-proxy identity headers.
- Non-loopback Control UI deployments still need explicit `gateway.controlUi.allowedOrigins`.
- **Forwarded-header evidence overrides loopback locality for local direct fallback.** If a request arrives on loopback but carries `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto` headers pointing at a non-local origin, that evidence disqualifies local-direct password fallback and device-identity gating. With `allowLoopback: true`, trusted-proxy auth can still accept the request as a same-host proxy request, while `requiredHeaders` and `allowUsers` continue to apply.
- **Forwarded-header evidence overrides loopback locality for local direct fallback.** If a request arrives on loopback but carries `Forwarded`, any `X-Forwarded-*`, or `X-Real-IP` header evidence, that evidence disqualifies local-direct password fallback and device-identity gating. With `allowLoopback: true`, trusted-proxy auth can still accept the request as a same-host proxy request, while `requiredHeaders` and `allowUsers` continue to apply.
</Warning>

View File

@@ -144,6 +144,7 @@ describe("gateway auth", () => {
{ name: "X-Forwarded-For", headers: { "x-forwarded-for": "203.0.113.10" } },
{ name: "X-Forwarded-Proto", headers: { "x-forwarded-proto": "https" } },
{ name: "X-Forwarded-Host", headers: { "x-forwarded-host": "gateway.example" } },
{ name: "X-Forwarded-User", headers: { "x-forwarded-user": "nick@example.com" } },
{ name: "X-Real-IP", headers: { "x-real-ip": "203.0.113.10" } },
])("treats $name as forwarded request evidence", ({ headers }) => {
const req = {
@@ -1024,7 +1025,7 @@ describe("trusted-proxy auth", () => {
expect(res.reason).toBe("trusted_proxy_loopback_source");
});
it("rejects local-direct password credentials when trusted-proxy auth fails", async () => {
it("accepts local-direct password fallback when trusted-proxy auth fails", async () => {
const limiter = createLimiterSpy();
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
@@ -1032,13 +1033,13 @@ describe("trusted-proxy auth", () => {
rateLimiter: limiter,
});
expect(res).toEqual({ ok: false, reason: "trusted_proxy_loopback_source" });
expect(limiter.check).not.toHaveBeenCalled();
expect(limiter.reset).not.toHaveBeenCalled();
expect(res).toEqual({ ok: true, method: "password" });
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.reset).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.recordFailure).not.toHaveBeenCalled();
});
it("ignores wrong local-direct password credentials when trusted-proxy auth fails", async () => {
it("rejects wrong local-direct password fallback and records the failure", async () => {
const limiter = createLimiterSpy();
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
@@ -1046,13 +1047,13 @@ describe("trusted-proxy auth", () => {
rateLimiter: limiter,
});
expect(res).toEqual({ ok: false, reason: "trusted_proxy_loopback_source" });
expect(limiter.check).not.toHaveBeenCalled();
expect(limiter.recordFailure).not.toHaveBeenCalled();
expect(res).toEqual({ ok: false, reason: "password_mismatch" });
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.reset).not.toHaveBeenCalled();
});
it("does not apply shared-secret rate limits to trusted-proxy failures", async () => {
it("enforces rate-limit lockout before local-direct password fallback", async () => {
const limiter = createLimiterSpy();
limiter.check.mockReturnValueOnce({
allowed: false,
@@ -1066,12 +1067,29 @@ describe("trusted-proxy auth", () => {
rateLimiter: limiter,
});
expect(res).toEqual({ ok: false, reason: "trusted_proxy_loopback_source" });
expect(limiter.check).not.toHaveBeenCalled();
expect(res).toEqual({
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: 2500,
});
expect(limiter.recordFailure).not.toHaveBeenCalled();
expect(limiter.reset).not.toHaveBeenCalled();
});
it("accepts local-direct password fallback before required-header failure", async () => {
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
connectPassword: "local-password", // pragma: allowlist secret
trustedProxy: {
...trustedProxyConfig,
allowLoopback: true,
},
});
expect(res).toEqual({ ok: true, method: "password" });
});
it("keeps local-direct trusted-proxy on proxy failure when no password is supplied", async () => {
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret

View File

@@ -125,13 +125,14 @@ export function hasForwardedRequestHeaders(req?: IncomingMessage): boolean {
if (!req) {
return false;
}
const headers = req.headers ?? {};
return Boolean(
req.headers?.forwarded ||
req.headers?.["x-forwarded-for"] ||
req.headers?.["x-forwarded-proto"] ||
req.headers?.["x-real-ip"] ||
req.headers?.["x-forwarded-host"],
headers.forwarded ||
headers["x-real-ip"] ||
Object.keys(headers).some((header) =>
normalizeLowercaseStringOrEmpty(header).startsWith("x-forwarded-"),
),
);
}
@@ -477,6 +478,26 @@ async function authorizeGatewayConnectCore(
}
return { ok: true, method: "trusted-proxy", user: result.user };
}
if (localDirect && auth.password && connectAuth?.password) {
if (limiter) {
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
if (!rlCheck.allowed) {
return {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: rlCheck.retryAfterMs,
};
}
}
return authorizePasswordAuth({
authPassword: auth.password,
connectPassword: connectAuth.password,
limiter,
ip,
rateLimitScope,
});
}
return { ok: false, reason: result.reason };
}