Skip to content

Valid admin-token /api/* requests are 403'd behind a TLS-terminating reverse proxy #760

Description

@nbsp1221

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

  1. 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.
  2. 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.
  3. 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 */ }
  1. 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.
  2. 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.
  3. 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

  1. 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
  1. 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"}'
  1. 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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingguiDashboard, tray, settings UIproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions