diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b6d78c1cb..3436c836961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ Docs: https://docs.openclaw.ai - Channels/Telegram: stop native approval startup auth failures from retrying every second, while still waiting through retryable Gateway auth handoffs, so Telegram approval setup problems no longer create a reconnect/log loop during channel startup. Refs #72846 and #72867. Thanks @kiranvk-2011 and @porly1985. - Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000. - Gateway/auth: allow local direct callers in trusted-proxy mode to use the configured gateway password as an internal fallback while keeping token fallback rejected. Fixes #17761. Thanks @dashed, @vincentkoc, and @jetd1. +- Gateway/auth: add explicit `trustedProxy.allowLoopback` support for same-host loopback reverse proxies while keeping loopback trusted-proxy auth fail-closed by default and preserving required-header and allowlist checks. Fixes #59167; carries forward #63379. Thanks @Matir, @jeremyakers, and @mrosmarin. - Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov. - Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz. - Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 945ffb7c9c0..15d51d27164 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -9f314a39cb208e0b32a94da939f15bccfcbbe093ade70cc59ac981be604ea53c config-baseline.json -7937564a6c8020b765b857b52b522beaa24d970f5743833716cd019b7147de10 config-baseline.core.json +eaa444149224b6cd634d034003c07f3c430d4e680e58aaa158e0038c1b03398e config-baseline.json +6aa2d317b20d73fba7b5f0d36dffc3d0c33796147b544434654d9fe4c1885c5f config-baseline.core.json c4f07c228d4f07e7afafa5b600b4a80f5b26aaed7267c7287a64d04a527be8e8 config-baseline.channel.json 1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index cc20bed62dc..c8d2fd57146 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -394,7 +394,7 @@ See [Plugins](/tools/plugin). - **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default. - If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset. - `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. -- `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source; same-host loopback reverse proxies do not satisfy trusted-proxy identity auth. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode. +- `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source by default; same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode. - `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`). HTTP API endpoints do **not** use that Tailscale header auth; they follow the gateway's normal HTTP auth mode instead. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - On the async Tailscale Serve Control UI path, failed attempts for the same `{scope, clientIp}` are serialized before the failure write. Concurrent bad attempts from the same client can therefore trip the limiter on the second request instead of both racing through as plain mismatches. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 30c1972a7cc..86ec14def17 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -40,8 +40,8 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). - When `gateway.auth.mode="trusted-proxy"`, the HTTP request must come from a - configured non-loopback trusted proxy source; same-host loopback proxies do - not satisfy this mode. + configured trusted proxy source; same-host loopback proxies require explicit + `gateway.auth.trustedProxy.allowLoopback = true`. - If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. ## Security boundary (important) diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index 9b6d2ebcb73..2c7876abd89 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -22,7 +22,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 ` - - trusted-proxy auth (`gateway.auth.mode="trusted-proxy"`): identity-aware proxy headers from a configured non-loopback trusted proxy source + - 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` - 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 diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 0e6c3936d68..f58e7cdc49c 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -154,8 +154,8 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need use that Tailscale header auth and instead follow the gateway's normal HTTP auth mode. This tokenless flow assumes the gateway host is trusted. Set it to `false` if you want shared-secret auth everywhere. -- **Trusted-proxy** auth is for non-loopback identity-aware proxy setups only. - Same-host loopback reverse proxies do not satisfy `gateway.auth.mode: "trusted-proxy"`. +- **Trusted-proxy** auth expects non-loopback identity-aware proxy setups by default. + Same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. - Treat browser control like operator access: tailnet-only + deliberate node pairing. Deep dive: [Security](/gateway/security). diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index b97df39301d..f4923f609a8 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -56,6 +56,7 @@ exhaustive): | `gateway.trusted_proxy_no_proxies` | critical | Trusted-proxy auth without trusted proxy IPs is unsafe | `gateway.trustedProxies` | no | | `gateway.trusted_proxy_no_user_header` | critical | Trusted-proxy auth cannot resolve user identity safely | `gateway.auth.trustedProxy.userHeader` | no | | `gateway.trusted_proxy_no_allowlist` | warn | Trusted-proxy auth accepts any authenticated upstream user | `gateway.auth.trustedProxy.allowUsers` | no | +| `gateway.trusted_proxy_allow_loopback` | warn | Trusted-proxy auth accepts explicitly allowed loopback proxy sources | `gateway.auth.trustedProxy.allowLoopback` | no | | `gateway.probe_auth_secretref_unavailable` | warn | Deep probe could not resolve auth SecretRefs in this command path | deep-probe auth source / SecretRef availability | no | | `gateway.probe_failed` | warn/critical | Live Gateway probe failed | gateway reachability/auth | no | | `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index f15c1abc703..9f56aacc759 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -144,7 +144,7 @@ a real boundary bypass is demonstrated: explicit CIDR/IP entries, only applies to first-time `role: node` pairing with no requested scopes, and does not auto-approve operator/browser/Control UI, WebChat, role upgrades, scope upgrades, metadata changes, public-key changes, - or same-host loopback trusted-proxy header paths. + or same-host loopback trusted-proxy header paths unless loopback trusted-proxy auth was explicitly enabled. - "Missing per-user authorization" findings that treat `sessionKey` as an auth token. @@ -347,9 +347,9 @@ When the Gateway detects proxy headers from an address that is **not** in `trust `gateway.trustedProxies` also feeds `gateway.auth.mode: "trusted-proxy"`, but that auth mode is stricter: -- trusted-proxy auth **fails closed on loopback-source proxies** -- same-host loopback reverse proxies can still use `gateway.trustedProxies` for local-client detection and forwarded IP handling -- for same-host loopback reverse proxies, use token/password auth instead of `gateway.auth.mode: "trusted-proxy"` +- trusted-proxy auth **fails closed on loopback-source proxies by default** +- same-host loopback reverse proxies can use `gateway.trustedProxies` for local-client detection and forwarded IP handling +- same-host loopback reverse proxies can satisfy `gateway.auth.mode: "trusted-proxy"` only when `gateway.auth.trustedProxy.allowLoopback = true`; otherwise use token/password auth ```yaml gateway: @@ -369,7 +369,7 @@ Trusted proxy headers do not make node device pairing automatically trusted. `gateway.nodes.pairing.autoApproveCidrs` is a separate, disabled-by-default operator policy. Even when enabled, loopback-source trusted-proxy header paths are excluded from node auto-approval because local callers can forge those -headers. +headers, including when loopback trusted-proxy auth is explicitly enabled. Good reverse proxy behavior (overwrite incoming forwarding headers): @@ -733,7 +733,7 @@ If you load canvas content in a normal browser, treat it like any other untruste Bind mode controls where the Gateway listens: - `gateway.bind: "loopback"` (default): only local clients can connect. -- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with gateway auth (shared token/password or a correctly configured non-loopback trusted proxy) and a real firewall. +- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with gateway auth (shared token/password or a correctly configured trusted proxy) and a real firewall. Rules of thumb: diff --git a/docs/gateway/tools-invoke-http-api.md b/docs/gateway/tools-invoke-http-api.md index 0a677647fcb..b3a89f08e05 100644 --- a/docs/gateway/tools-invoke-http-api.md +++ b/docs/gateway/tools-invoke-http-api.md @@ -34,8 +34,8 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). - When `gateway.auth.mode="trusted-proxy"`, the HTTP request must come from a - configured non-loopback trusted proxy source; same-host loopback proxies do - not satisfy this mode. + configured trusted proxy source; same-host loopback proxies require explicit + `gateway.auth.trustedProxy.allowLoopback = true`. - If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. ## Security boundary (important) diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index 14dd9a4b7d2..96b070e64df 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -64,7 +64,7 @@ Implications: ```json5 { gateway: { - // Trusted-proxy auth expects requests from a non-loopback trusted proxy source + // Trusted-proxy auth expects requests from a non-loopback trusted proxy source by default bind: "lan", // CRITICAL: Only add your proxy's IP(s) here @@ -81,6 +81,9 @@ Implications: // Optional: restrict to specific users (empty = allow all) allowUsers: ["nick@example.com", "admin@company.org"], + + // Optional: allow a same-host loopback proxy after explicit opt-in + allowLoopback: false, }, }, }, @@ -90,11 +93,12 @@ Implications: **Important runtime rules** -- Trusted-proxy auth rejects loopback-source requests (`127.0.0.1`, `::1`, loopback CIDRs). -- Same-host loopback reverse proxies do **not** satisfy trusted-proxy auth. -- For same-host loopback proxy setups, use token/password auth instead, or route through a non-loopback trusted proxy address that OpenClaw can verify. +- Trusted-proxy auth rejects loopback-source requests (`127.0.0.1`, `::1`, loopback CIDRs) by default. +- Same-host loopback reverse proxies do **not** satisfy trusted-proxy auth unless you explicitly set `gateway.auth.trustedProxy.allowLoopback = true` and include the loopback address in `gateway.trustedProxies`. +- `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.** 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 the loopback locality claim. The request is treated as remote for pairing, trusted-proxy auth, and Control UI device-identity gating. This prevents a same-host loopback proxy from laundering forwarded-header identity into trusted-proxy auth. +- **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. ### Configuration reference @@ -114,6 +118,13 @@ Implications: Allowlist of user identities. Empty means allow all authenticated users. + + Opt-in support for same-host loopback reverse proxies. Defaults to `false`. + + + +Only enable `allowLoopback` when the local reverse proxy is the intended trust boundary. Any local process that can connect to the Gateway can try to send proxy identity headers, so keep direct Gateway access private to the host and require proxy-owned headers such as `x-forwarded-proto` or a signed assertion header where your proxy supports one. + ## TLS termination and HSTS @@ -321,7 +332,7 @@ Before enabling trusted-proxy auth, verify: - [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy. - [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets. -- [ ] **No loopback proxy source**: trusted-proxy auth fails closed for loopback-source requests. +- [ ] **Loopback proxy source is deliberate**: trusted-proxy auth fails closed for loopback-source requests unless `gateway.auth.trustedProxy.allowLoopback` is explicitly enabled for a same-host proxy. - [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients. - [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS. - [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins`. @@ -339,6 +350,7 @@ The audit checks for: - Missing `trustedProxies` configuration - Missing `userHeader` configuration - Empty `allowUsers` (allows any authenticated user) +- Enabled `allowLoopback` for same-host proxy sources - Wildcard or missing browser-origin policy on exposed Control UI surfaces ## Troubleshooting @@ -362,8 +374,9 @@ The audit checks for: Fix: - - Use token/password auth for same-host loopback proxy setups, or - - Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`. + - Prefer token/password auth for internal same-host clients that do not go through the proxy, or + - Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`, or + - For a deliberate same-host reverse proxy, set `gateway.auth.trustedProxy.allowLoopback = true`, keep the loopback address in `gateway.trustedProxies`, and make sure the proxy strips or overwrites identity headers. diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index ff9a9e947fe..c26f1eefa95 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -121,7 +121,7 @@ and troubleshooting see the main [FAQ](/help/faq). - **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https:///`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy Control UI/WebSocket auth (no pasted shared secret, assumes trusted gateway host); HTTP APIs still require shared-secret auth unless you deliberately use private-ingress `none` or trusted-proxy HTTP auth. Bad concurrent Serve auth attempts from the same client are serialized before the failed-auth limiter records them, so the second bad retry can already show `retry later`. - **Tailnet bind**: run `openclaw gateway --bind tailnet --token ""` (or configure password auth), open `http://:18789/`, then paste the matching shared secret in dashboard settings. - - **Identity-aware reverse proxy**: keep the Gateway behind a non-loopback trusted proxy, configure `gateway.auth.mode: "trusted-proxy"`, then open the proxy URL. + - **Identity-aware reverse proxy**: keep the Gateway behind a trusted proxy, configure `gateway.auth.mode: "trusted-proxy"`, then open the proxy URL. Same-host loopback proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. - **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`. Shared-secret auth still applies over the tunnel; paste the configured token or password if prompted. See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details. diff --git a/docs/help/faq.md b/docs/help/faq.md index d5d8487bd92..e70a9aa256f 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -669,7 +669,7 @@ lives on the [First-run FAQ](/help/faq-first-run). Non-loopback binds **require a valid gateway auth path**. In practice that means: - shared-secret auth: token or password - - `gateway.auth.mode: "trusted-proxy"` behind a correctly configured non-loopback identity-aware reverse proxy + - `gateway.auth.mode: "trusted-proxy"` behind a correctly configured identity-aware reverse proxy ```json5 { @@ -690,14 +690,14 @@ lives on the [First-run FAQ](/help/faq-first-run). - For password auth, set `gateway.auth.mode: "password"` plus `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`) instead. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - Shared-secret Control UI setups authenticate via `connect.params.auth.token` or `connect.params.auth.password` (stored in app/UI settings). Identity-bearing modes such as Tailscale Serve or `trusted-proxy` use request headers instead. Avoid putting shared secrets in URLs. - - With `gateway.auth.mode: "trusted-proxy"`, same-host loopback reverse proxies still do **not** satisfy trusted-proxy auth. The trusted proxy must be a configured non-loopback source. + - With `gateway.auth.mode: "trusted-proxy"`, same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true` and a loopback entry in `gateway.trustedProxies`. OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and auto-generates one, saving it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. - If you prefer a different auth path, you can explicitly choose password mode (or, for non-loopback identity-aware reverse proxies, `trusted-proxy`). If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. + If you prefer a different auth path, you can explicitly choose password mode (or, for identity-aware reverse proxies, `trusted-proxy`). If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. @@ -1468,7 +1468,7 @@ lives on the [Models FAQ](/help/faq-models). - If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`. - Shared-secret mode: set `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, then paste the matching secret in Control UI settings. - Tailscale Serve mode: make sure `gateway.auth.allowTailscale` is enabled and you are opening the Serve URL, not a raw loopback/tailnet URL that bypasses Tailscale identity headers. - - Trusted-proxy mode: make sure you are coming through the configured non-loopback identity-aware proxy, not a same-host loopback proxy or raw gateway URL. + - Trusted-proxy mode: make sure you are coming through the configured identity-aware proxy, not a raw gateway URL. Same-host loopback proxies also need `gateway.auth.trustedProxy.allowLoopback = true`. - If mismatch persists after the one retry, rotate/re-approve the paired device token: - `openclaw devices list` - `openclaw devices rotate --device --role operator` diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index f620196370e..f41e6b26a58 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -22198,6 +22198,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", }, }, + allowLoopback: { + type: "boolean", + }, }, required: ["userHeader"], additionalProperties: false, diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 57ac647fe9e..088d031cc6e 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -141,6 +141,12 @@ export type GatewayTrustedProxyConfig = { * Example: ["nick@example.com", "admin@company.org"] */ allowUsers?: string[]; + /** + * Allow loopback proxy sources (127.0.0.1, ::1) in trusted-proxy mode. + * Default false; enable only when a same-host reverse proxy is the intended + * trust boundary and direct Gateway access is otherwise locked down. + */ + allowLoopback?: boolean; }; export type GatewayAuthConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1394c47aa10..1c4801a3e0e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -766,6 +766,7 @@ export const OpenClawSchema = z userHeader: z.string().min(1, "userHeader is required for trusted-proxy mode"), requiredHeaders: z.array(z.string()).optional(), allowUsers: z.array(z.string()).optional(), + allowLoopback: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index ecbd290a616..44814b74819 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1047,6 +1047,88 @@ describe("trusted-proxy auth", () => { expect(res.reason).toBe("trusted_proxy_loopback_source"); }); + it("accepts same-host trusted-proxy identity headers when loopback is explicitly allowed", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + ...trustedProxyConfig, + allowLoopback: true, + }, + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res).toEqual({ + ok: true, + method: "trusted-proxy", + user: "nick@example.com", + }); + }); + + it("keeps required header checks for explicitly allowed loopback proxies", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + ...trustedProxyConfig, + allowLoopback: true, + }, + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-user": "nick@example.com", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto"); + }); + + it("keeps allowUsers checks for explicitly allowed loopback proxies", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowUsers: ["admin@example.com"], + allowLoopback: true, + }, + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_user_not_allowed"); + }); + it("fails closed when forwarded headers are present but the client chain resolves to loopback", async () => { const res = await authorizeGatewayConnect({ auth: { diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 705c9e67b74..0cd3e9a3dd8 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -275,7 +275,7 @@ function authorizeTrustedProxy(params: { if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) { return { reason: "trusted_proxy_untrusted_source" }; } - if (isLoopbackAddress(remoteAddr)) { + if (isLoopbackAddress(remoteAddr) && trustedProxyConfig.allowLoopback !== true) { return { reason: "trusted_proxy_loopback_source" }; } diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts index 054d1f809b6..018ce01490f 100644 --- a/src/security/audit-gateway-config.ts +++ b/src/security/audit-gateway-config.ts @@ -326,6 +326,20 @@ export function collectGatewayConfigFindings( }); } + if (trustedProxyConfig?.allowLoopback === true) { + findings.push({ + checkId: "gateway.trusted_proxy_allow_loopback", + severity: "warn", + title: "Trusted-proxy auth allows loopback proxy sources", + detail: + "gateway.auth.trustedProxy.allowLoopback=true allows loopback-source requests " + + "from configured gateway.trustedProxies entries to satisfy trusted-proxy auth.", + remediation: + "Enable this only when a same-host reverse proxy is the intended trust boundary. " + + "Keep direct Gateway access private to the host and require the proxy to strip or overwrite identity headers.", + }); + } + const allowUsers = trustedProxyConfig?.allowUsers ?? []; if (allowUsers.length === 0) { findings.push({ diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index 823c631ea41..e0f32e8a56d 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -380,6 +380,25 @@ describe("security audit gateway exposure findings", () => { expectedCheckId: "gateway.trusted_proxy_no_allowlist", expectedSeverity: "warn", }, + { + name: "loopback proxy source explicitly allowed", + cfg: { + gateway: { + bind: "loopback", + trustedProxies: ["127.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["nick@example.com"], + allowLoopback: true, + }, + }, + }, + }, + expectedCheckId: "gateway.trusted_proxy_allow_loopback", + expectedSeverity: "warn", + }, ]; for (const testCase of cases) {