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 returns null immediately, 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.cookie does not appear in the built GUI, no request uses credentials: "include", and set-cookie appears in src/ 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 whatever Host arrived. The DNS-rebinding value of the predicate lives in exactly that skipped branch; with a token required, an attacker who satisfies Origin === Host-derived origin still has nothing without the credential.
- There is no
X-Forwarded-Proto (or any X-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.corsAllowOrigins is 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.
Host is what the proxy forwards; the process sees http: 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,
Origin is 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
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
Client or integration
OpenCodex dashboard
Area
Dashboard
Summary
On a non-loopback bind, every
/api/*request carrying anOriginheader whose scheme differs from the scheme the proxy observes is rejected with403 {"error":"cross-origin request blocked"}— even when it carries a validOPENCODEX_ADMIN_AUTH_TOKEN.When a reverse proxy terminates TLS, which is the deployment recommended in #95, the browser sends
Origin: https://proxy.example.comwhile the proxy seeshttp:. Browsers omitOriginon 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.mddocuments this deployment as supported — "A non-loopback dashboard uses the management token flow instead" — andrequireManagementAuth()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
Hostset the way a reverse proxy sends it. Only theOriginscheme differs between the three requests:The 502 is unrelated handler logic in this container; what matters is that those two requests pass admission and reach the handler while the
httpsone does not.Where it comes from
src/server/management-api.tsgates the whole management surface at entry, before any credential is examined:src/server/auth-cors.tsderives the expected origin from the scheme visible inside the process:The same predicate also gates
OPTIONSpreflight insrc/server/index.ts, so the browser fails at preflight for any non-simple request.Why this looks broader than intended
/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.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.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.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
{ "port": 10100, "hostname": "0.0.0.0" }Hostis what the proxy forwards; the process seeshttp:either way: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
Origingrounds, 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 honouringX-Forwarded-Protowhen 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
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:
header_up Origin http://proxy.example.com, which keeps TLS and restores the dashboard;Additional context
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.tsgate andmanagementRequestOrigin()are unchanged there). Happy to open a PR againstdevif you agree on the direction;src/server/auth-cors.tsis in the CODEOWNERS authentication group, so I would rather settle the shape here first.Checks