Repository navigation
Valid admin-token /api/* requests are 403'd behind a TLS-terminating reverse proxy #760
Description
Client or integration
OpenCodex dashboard
Area
Dashboard
Summary
On a non-loopback bind, every /api/* request carrying an Origin header whose scheme differs from the scheme the proxy observes is rejected with 403 {"error":"cross-origin request blocked"} — even when it carries a valid OPENCODEX_ADMIN_AUTH_TOKEN.
When a reverse proxy terminates TLS, which is the deployment recommended in #95, the browser sends Origin: https://proxy.example.com while the proxy sees http:. Browsers omit Origin on same-origin GETs and include it on state-changing requests, so the practical result is a dashboard that loads and reads fine but cannot change anything: adding a provider, toggling a model, or starting an OAuth login all return 403.
structure/05_gui-and-management-api.md documents this deployment as supported — "A non-loopback dashboard uses the management token flow instead" — and requireManagementAuth() deliberately admits the management token with no origin check. The rejection comes from a separate gate applied before credentials are considered.
Measured on 2.7.43 in Docker, with a correct admin token and Host set the way a reverse proxy sends it. Only the Origin scheme differs between the three requests:
PUT /api/v2 no Origin -> 502 {"error":"multi_agent_v2 transition failed: ..."}
PUT /api/v2 Origin: https://proxy.example.com -> 403 {"error":"cross-origin request blocked"}
PUT /api/v2 Origin: http://proxy.example.com -> 502 {"error":"multi_agent_v2 transition failed: ..."}
The 502 is unrelated handler logic in this container; what matters is that those two requests pass admission and reach the handler while the https one does not.
Where it comes from
src/server/management-api.ts gates the whole management surface at entry, before any credential is examined:
export async function handleManagementAPI(req, url, config, deps = {}) {
if (!isAllowedManagementOrigin(req, config)) {
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
}src/server/auth-cors.ts derives the expected origin from the scheme visible inside the process:
export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
const host = req.headers.get("Host");
const parsedHost = parseHttpHost(host);
if (!host || !parsedHost) return null;
if (!isApiAuthRequired(config) && !isLoopbackHostname(parsedHost.hostname)) return null;
try {
const protocol = new URL(req.url).protocol; // always http: behind TLS termination
...The same predicate also gates OPTIONS preflight in src/server/index.ts, so the browser fails at preflight for any non-simple request.
Why this looks broader than intended
- The invariants doc lists non-loopback dashboards as a supported mode through the management token flow. The only deliberate lockdown it records in that area is iframe embedding, which it calls out explicitly as "intentionally unsupported"; reverse proxies are not mentioned.
- Its credential table binds origin to one class only: "GUI session —
/api/*only, bound to the issuing origin". The management-plane row carries no origin constraint. requireManagementAuth()matches that: a valid management token returnsnullimmediately, with the origin and CSRF checks living in the session branch below it.
if (actual && equalSecret(actual, state.token)) return null; // no origin check
if (actual && config) { /* session branch: origin + CSRF */ }- CSRF, the usual reason for a same-origin gate, cannot apply here: no credential is cookie-borne.
document.cookiedoes not appear in the built GUI, no request usescredentials: "include", andset-cookieappears insrc/only inside header redaction and relay blocklists. A cross-origin page cannot make the browser attach either the admin token or the session token, both of which are explicit headers. - In token mode the comparison is close to self-referential.
managementRequestOrigin()skips its!isApiAuthRequired(config) && !isLoopbackHostname(...)guard when a token is required, so it returns the origin derived from whateverHostarrived. The DNS-rebinding value of the predicate lives in exactly that skipped branch; with a token required, an attacker who satisfiesOrigin === Host-derived origin still has nothing without the credential. - There is no
X-Forwarded-Proto(or anyX-Forwarded-*) handling in the source, so a deployment behind TLS termination has no way to declare its external origin. Not trusting proxy headers by default is reasonable, but there is also no trusted-proxy or allowed-management-origin setting to opt in with.config.corsAllowOriginsis not consulted by this path.
This is structurally the same shape as #570: a same-origin family predicate applied more broadly than the trust decision needs, taking legitimate access down with it.
Reproduction
- Start the proxy with a non-loopback bind and both credentials set:
{ "port": 10100, "hostname": "0.0.0.0" }export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"
export OPENCODEX_ADMIN_AUTH_TOKEN="$(openssl rand -hex 32)"
ocx start- Put any TLS-terminating reverse proxy in front of it, or reproduce the same request shape directly.
Hostis what the proxy forwards; the process seeshttp:either way:
PROXY=http://127.0.0.1:10100
# reaches the handler
curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$PROXY/api/v2" \
-H 'Host: proxy.example.com' \
-H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \
-H 'Content-Type: application/json' -d '{"multiAgentMode":"v2"}'
# 403, same token, only Origin added as a browser would
curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$PROXY/api/v2" \
-H 'Host: proxy.example.com' -H 'Origin: https://proxy.example.com' \
-H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \
-H 'Content-Type: application/json' -d '{"multiAgentMode":"v2"}'
# reaches the handler again once the scheme matches
curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$PROXY/api/v2" \
-H 'Host: proxy.example.com' -H 'Origin: http://proxy.example.com' \
-H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \
-H 'Content-Type: application/json' -d '{"multiAgentMode":"v2"}'- In a browser through the TLS-terminating proxy this shows up as a dashboard that loads and populates its read-only panels while every mutation returns 403, and preflight for non-simple requests returns 403 as well. I established that from the header behaviour above rather than by instrumenting the browser: per Fetch,
Originis omitted on same-origin GET and sent on state-changing requests.
Expected behaviour
A request bearing a valid management-plane token should not be rejected on Origin grounds, since that credential is never attached automatically by a browser. Alternatively, a supported way to declare the external origin — a trusted-proxy setting, an allowed-management-origins list, or honouring X-Forwarded-Proto when explicitly enabled — would make the documented non-loopback dashboard deployment usable behind TLS.
Version
2.7.43
Operating system
Ubuntu 24.04.4 LTS (proxy in Docker); browser client on Windows 11
Logs or error output
$ curl -s -X PUT "$PROXY/api/v2" -H 'Host: proxy.example.com' \
-H 'Origin: https://proxy.example.com' \
-H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \
-H 'Content-Type: application/json' -d '{"multiAgentMode":"v2"}'
{"error":"cross-origin request blocked"}Redacted configuration
{
"port": 10100,
"hostname": "0.0.0.0",
"corsAllowOrigins": ["https://proxy.example.com"]
}Workarounds, for anyone landing here
All three work today, and all three are detours around a documented deployment:
- rewrite the header at the proxy, e.g. Caddy
header_up Origin http://proxy.example.com, which keeps TLS and restores the dashboard; - reach the dashboard over an SSH tunnel on the same port, where scheme, host, and port all match (measured: preflight 204, GET 200);
- serve the site over plain HTTP so the schemes match.
Additional context
- Port-remapped tunnels are rejected by the loopback Host check: dashboard loads, every API call 403s #570 — the same family of predicate applied too broadly, since fixed.
- Clarify or harden management API access when binding beyond loopback #24 — the earlier hardening request this management-plane split grew out of.
- [Roadmap] Centrally hosted multi-user OpenCodex with tenant isolation #95 — recommends the TLS-terminating reverse proxy deployment that this affects.
- The credential split itself (
feat(security): split management and data credentials) works exactly as documented; addingOPENCODEX_ADMIN_AUTH_TOKENwas a one-line change on my side. Only the origin gate is at issue.
Verified against 2.7.43 and the current dev (src/server/management-api.ts gate and managementRequestOrigin() are unchanged there). Happy to open a PR against dev if you agree on the direction; src/server/auth-cors.ts is in the CODEOWNERS authentication group, so I would rather settle the shape here first.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Jul 30, 2026
Automated translation bookkeeping — detected language: unknown.
Wibias commentedon Jul 30, 2026
[shipping-github] Research review
Claim: Valid admin-token
/api/*mutations are 403'd withcross-origin request blockedwhen TLS is terminated upstream (Originhttpsvs proxyhttp).Checked against: dev@be177ea5
isAllowedManagementOrigincompares browserOriginto origin derived fromnew URL(req.url).protocol+Host; noX-Forwarded-Proto/ trusted external origin for the management gate;corsAllowOriginsis not consulted thereEvidence:
src/server/management-api.tsorigin gate before auth;managementRequestOrigin/isAllowedManagementOrigininsrc/server/auth-cors.ts.requireManagementAuthtoken path has no origin check, but never reached after the outer gate.Gaps: No live reverse-proxy repro in this session; reporter curl matrix is convincing.
Wibias commentedon Jul 30, 2026
[shipping-github] Security review
Checked against: dev@be177ea5
Scope: Management
Origingate vs TLS-terminating reverse proxies; interaction with admin-token and GUI-session auth/api/*requests when the browserOriginscheme disagrees with the scheme the process observes, even after a valid management credential is accepted. This is an availability break of the management plane, not evidence of a token bypass on tip.Summary: Tip behaviour over-blocks legitimate admin-token browser use behind TLS termination. Related family: #570.
Residual: Session cookies are not used; bearer/header credentials remain the CSRF boundary for token mode — do not remove session origin checks while fixing token mode.
Wibias commentedon Jul 30, 2026
[shipping-github] Opened PR #779 to address this.
fix: address issue #760
fix: address issue lidge-jun#760