mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user