Repository navigation
fix(nginx): stop the Control UI Bearer token from clobbering basic auth - #87
Conversation
The Control UI authenticates to the gateway with `Authorization: Bearer <token>`. A request carries only one Authorization header, so on every fetch the UI makes itself, that Bearer replaces the browser's Basic credentials. auth_basic cannot parse it, returns 401 + WWW-Authenticate, and the browser prompts again. Entering the correct password cannot help: the next UI fetch overwrites the header again. This is the same class of bug as coollabsio#37 (webhook Authorization header overwritten by the nginx proxy), and it is fixed the same way PR coollabsio#38 fixed that one: skip basic auth for the affected routes and pass the client's token through so the gateway validates it. Scoped to routes the gateway itself authenticates (/api/, /__openclaw__/, /rpc, /control-ui-config.json). "/" is deliberately excluded: the gateway serves the UI shell unauthenticated, so exempting it would let any bogus Bearer past basic auth. The nginx-injected `token=` query parameter is suppressed on passthrough requests for the same reason, otherwise the carve-out would hand out authenticated access instead of delegating it. When AUTH_PASSWORD is unset the generated config is byte-identical to before. Refs coollabsio#65
|
Spent a few days chasing HTTP Auth issue. I kept getting the user/pass prompt infinitely when loading the OpenClaw UI. The Control UI calls the gateway with Authorization: Bearer , and a request only gets one Authorization header, so every fetch the UI makes overwrites the Basic credentials the browser sent. nginx can't read that as Basic, 401s, and you get prompted again. That's why the right password never sticks. It works for whatever the browser fetches on its own (page, fonts, WebSocket), then the app makes one call and you're back at the login box. Feels random because it mostly fires on avatars and workspace icons. @matjaz's manifest crossorigin tip was right and has already shipped, so this is the part that was left. One thing worth flagging: deleting AUTH_USERNAME / AUTH_PASSWORD does stop the prompt, but nginx injects the gateway token on every proxied request, so it leaves the Control UI with no auth in front of it at all. Fine on a private network, less so on a public box. |
Skip basic auth only for the exact paths the Control UI fetches with Authorization: Bearer, and forward the client's header instead of injecting the gateway token. Anchor every regex. Keep /, the WebSocket, and /browser/ on basic auth, and leave the generated config unchanged when AUTH_PASSWORD is unset. Trust the loopback nginx hop via gateway.trustedProxies and overwrite X-Forwarded-For with the TCP peer so auth rate limits are not stuck on 127.0.0.1. Co-authored-by: Andras Bacsai <andrasbacsai@users.noreply.github.com>
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 36526964 | Triggered | Generic Password | dcaa92b | scripts/nginx-bearer.test.js | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
Fixes the repeated basic-auth prompt reported in #65.
Root cause
The Control UI authenticates to the gateway with
Authorization: Bearer <token>. A request carries only oneAuthorizationheader, so on every fetch the UI makes itself, that Bearer replaces the browser's Basic credentials.auth_basiccannot parse it as Basic, returns401 + WWW-Authenticate: Basic, and the browser prompts again.Entering the correct password provably cannot stop it. The password only helps browser-managed requests (navigations, fonts, the WebSocket), and the next fetch the app makes overwrites the header again. It reads as intermittent because it fires when the UI loads workspace icons and avatars.
Measured on a live agent with an nginx log of the auth scheme per request (never the value):
Every one of those URLs returns
200from curl with the correct Basic credentials, so the gateway and the credentials were never the problem.Note this is not the
<link rel="manifest">issue diagnosed in the #65 thread. That fix has already shipped (the UI servescrossorigin="use-credentials"), and the prompt persists without this change.This is the same bug as #37, fixed the same way
#37 was "Webhook Authorization header overwritten by nginx proxy", and #38 fixed it by skipping basic auth for the hook path and passing the client's token through for OpenClaw to validate, which is why
HOOKS_PATHcarriesproxy_set_header Authorization $http_authorization. This applies that same treatment to the UI's own gateway routes.What changed
scripts/entrypoint.shonly. WhenAUTH_PASSWORDis set, the generated nginx config gains four maps so that:auth_basicresolves to"off"only for a Bearer-carrying request to a route the gateway itself authenticates:/api/,/__openclaw__/,/rpc,/control-ui-config.json$http_authorization) instead of the injectedBearer ${GATEWAY_TOKEN}, so the gateway validates ittoken=query parameter is suppressed on exactly those requestsTwo deliberate constraints, both load-bearing:
/is not in the route list. The gateway serves the UI shell unauthenticated, so exempting/would let any bogus Bearer walk straight past basic auth. An earlier draft of this patch did exactly that and returned200with the UI toAuthorization: Bearer bogus.token=${GATEWAY_TOKEN}to every proxied request, so relaxing only the header would have handed out fully authenticated access rather than delegating validation./browser/keeps unconditional basic auth: it is not a gateway route, so the realm always resolves to"Openclaw".Validation
AUTH_PASSWORDunset the generated config is byte-identical to before (generated both ways from the real entrypoint code and diffed; empty).nginx -tpasses on the generated config.node scripts/utils.test.js: 81 passed, 0 failed.node --check scripts/configure.js,node --check scripts/utils.js,bash -n scripts/entrypoint.sh.git diff --checkclean.End-to-end against a stub gateway that accepts the token via Bearer or
?token=and rejects anything else. The response body shows which layer rejected://api/x//browser//api/xSTUB-GATEWAY-REJECTED/control-ui-config.jsonSTUB-GATEWAY-REJECTED/STUB-GATEWAY-OK/api/xSTUB-GATEWAY-OK/api/xSTUB-GATEWAY-OK/__openclaw__/ySTUB-GATEWAY-OK/control-ui-config.jsonSTUB-GATEWAY-OKAlso running in production on a fleet of 9 agents as an equivalent out-of-tree patch: Bearer 401s went from 134 to 0 in browser traffic, with 158 Basic
200/304alongside and the prompt gone.Open question for maintainers
The four route prefixes are hardcoded, mirroring how
HOOKS_PATHis handled. Happy to make them configurable if you would prefer that.Fixes #65
Closes #66
Narrows and verifies @codezz's nginx fix so the Control UI can send
Authorization: Bearerwithout making the browser re-prompt for basic auth.Root cause
The Control UI authenticates to the gateway with
Authorization: Bearer <token>(per-device token from the WebSocket handshake, then the stored gateway token, then the password). A request has only oneAuthorizationheader, so that Bearer replaces the browser's Basic credentials.auth_basiccannot parse it, returns401+WWW-Authenticate: Basic, and the browser prompts again. Entering the correct password cannot stop it.This is the same class of bug as #37, fixed the same way as #38: skip basic auth for those routes and let the gateway validate the client's token. nginx must not inject its own
Bearerheader ortoken=query parameter on those requests.What changed
scripts/entrypoint.sh, only whenAUTH_PASSWORDis set. When it is unset, the generated nginx config is byte-identical to main.Basic auth is skipped only when the header is
Bearer(including an empty token) and the path is one of the exact routes below. Every regex is anchored./, the WebSocket, and/browser/stay on basic auth./rpcis not bypassed (gateway RPC is the WebSocket; an unanchored^/rpcmatch would also exempt/rpcanything).scripts/configure.jssetsgateway.trustedProxiesto127.0.0.1and::1when the key is not already set. nginx overwritesX-Forwarded-Forwith$remote_addrso OpenClaw's auth rate limit sees nginx's TCP peer instead of treating every client as loopback (loopback is exempt from lockout). This does not switchgateway.auth.mode; token auth stays in place. A customtrustedProxieslist is left alone.Routes
Discovered against a real OpenClaw 2026.10.5 gateway (Control UI source plus Playwright). The UI, once paired, called the first four with Bearer. The rest are the other Bearer fetches in that build (chat media, avatars, workspace and plugin icons, browser/assistant media). A fresh session has no chat attachment, so those were checked with the same
Authorization: Bearerheader the UI sends./control-ui-config.jsonGET/HEAD./__openclaw__/control-ui-config.json/__openclaw__base path./api/users/<id>/avatar/api/users/gateway-owner/avatar./avatar/<id>/__openclaw__/assistant-media/__openclaw__/workspace-icon/<id>/__openclaw__/plugin-icon/<id>/__openclaw__/plugin-activity-icon/<id>/__openclaw__/catalog-icon/<encoded>/__openclaw__/link-favicon/<host>/__openclaw__/channel-avatar/<id>/__openclaw__/plugin-theme-art/<a>/<b>/<c>/<d>/__openclaw__/plugins/control-ui/<id>/<64-hex>/<file>/api/chat/media/outgoing/<session>/<uuid>/full|thumbnailAnything else, including
/,/rpc,/rpcanything,/api/<other>, and/__openclaw__/<other>, still requires basic auth.Verification
Image built from the current Dockerfiles with
OPENCLAW_GIT_REF=v2026.10.5(the separatepnpm ui:buildlayer hitsEXDEVon overlayfs; the test build deleteddist/control-uiin that layer and rebuilt it.Dockerfile.baseis not changed here). Ran withAUTH_PASSWORDandOPENCLAW_GATEWAY_TOKENset.Playwright logged in with basic auth, submitted the gateway token, and a pending device was approved with
openclaw devices approve. On current 2026.10.5 that pairing step happens for a non-loopback client; it is not introduced by the bearer bypass.Browser, before (main nginx, trusted proxies only so the page can load)
Bearer requests were rejected by nginx (
401+WWW-Authenticate: Basic). The UI then failed to stay on the page.GET /api/users/gateway-owner/avatarBearerGET /control-ui-config.jsonBearerHEAD /control-ui-config.jsonBearerWithout
gateway.trustedProxies, the same main image returns403 proxy_attribution_requiredfor an authenticatedGET /, because nginx already sendsX-Forwarded-*and OpenClaw 2026.10.5 rejects unattributable proxy traffic.Browser, after (this patch)
248 responses, 164 of them Bearer, zero
401s. The session connected and Plugins / Agents rendered.GET /control-ui-config.jsonBearerGET /api/users/gateway-owner/avatarBearerGET /__openclaw__/plugin-icon/<id>BearerGET /__openclaw__/catalog-icon/<encoded>BearerInvalid and empty Bearer
For each bypassed path,
Authorization: Bearer not-a-tokenandAuthorization: Bearer(empty) were answered by the gateway with401{"type":"unauthorized"}and noWWW-Authenticate: Basic. A following valid gateway token got200or404, never the UI shell and never nginx. After enough failures the gateway returned429rate_limitedwithRetry-After: 300for that client. Gateway logs showforwardedFor=172.17.0.1(the Docker bridge peer) whileremoteAddr=127.0.0.1. A client-suppliedX-Forwarded-For: 8.8.8.8did not create a separate rate-limit identity.//control-ui-config.json//browser//rpcand/rpcanything/api/not-a-route/__openclaw__/not-a-route//browser/node scripts/nginx-bearer.test.js(55) andnode scripts/utils.test.js(81) pass. The nginx test checks the route regexes are anchored and thatAUTH_PASSWORDunset matches main byte for byte.Remaining risk
Docker port publishing hides the real client. nginx's peer is usually one bridge address, so the gateway rate-limits that shared address, not each visitor. A burst of bad tokens can lock that address for the whole host. Behind another proxy (Coolify's proxy), the peer is that proxy, same limitation, unless an operator sets a broader
gateway.trustedProxieslist themselves. This change does not try to trust private ranges automatically, because on Docker that would let any client spoofX-Forwarded-For.