Summary
Reaching the proxy through a port-remapping tunnel (ssh -L 20100:localhost:10100, a devcontainer/Codespaces forward, or any hostname alias) makes the dashboard load but leaves every API call rejected with 403. The proxy is healthy; the admission gate rejects the request before routing.
The cause is one predicate in src/server/auth-cors.ts:
export function isLoopbackRequestHost(value: string | null): boolean {
const parsed = parseHttpHost(value);
if (!parsed) return true;
if (!isLoopbackHostname(parsed.hostname)) return false;
return parsed.port === "" || parsed.port === configuredPort(); // <-- here
}
isAllowedRequestOrigin() early-returns false when this is false, regardless of the Origin header — including when there is none. So this is not a CORS problem: Codex CLI, Claude Code, and curl are rejected the same way a browser is.
Scope, measured. Same-port forwarding works today and is unaffected. Only remapped ports and host aliases break:
SAME-PORT | GET /v1/models | Host=localhost:56030 -> 200
SAME-PORT | GET /api/config | Host=localhost:56030 -> 200
REMAP | GET /v1/models | Host=localhost:29999 -> 403
REMAP | GET /api/config | Host=localhost:29999 -> 403
REMAP | GET /healthz | Host=localhost:29999 -> 200
REMAP | GET / | Host=localhost:29999 -> 200
ALIAS | GET /api/config | Host=myhost.lan:56030 -> 403
/ and /healthz pass while everything else fails, which is why the reported symptom is "the dashboard opens but nothing works" rather than a connection error.
Workaround (no code change): forward the same port — ssh -L 10100:localhost:10100 remote.
Why this matters
The gate is on the data plane, not just management routes: src/server/index.ts 322 (OPTIONS preflight), 336 (Responses WS upgrade), 373 /v1/models, 443 /v1/responses/compact, 475 /v1/images/*, 494 /v1/alpha/search, 518 /v1/responses, 558/573 /v1/messages*, 595 /v1/chat/completions, 618/646 /v1/live. A port-remapped tunnel therefore fails every inference call, not only the dashboard.
How the inconsistency arose
c29ee783e (2026-06-27) introduced port-equality on both isLoopbackRequestHost (Host) and isLoopbackOriginValue (Origin) — a "same origin as the dashboard" trust rule.
e4e06125b (2026-07-05, "fix: allow CORS from any loopback origin regardless of port") relaxed the Origin side, reasoning that browser apps on other localhost ports were blocked. The Host side was left untouched.
Origin is port-agnostic today; Host is still port-pinned. The relaxation argument from e4e06125b applies equally to Host, but was never carried over.
Security boundary
.github/CODEOWNERS assigns /src/server/auth-cors.ts to the "Authentication, credentials, and management API" group. Per MAINTAINERS.md and AGENTS.md, this needs explicit security review — it is not a small predicate tweak.
The threat model must be stated before changing anything, because loopback binds have no authentication at all: isApiAuthRequired = !isLoopbackHostname(config.hostname), so OPENCODEX_API_AUTH_TOKEN cannot be enforced while bound to loopback. Today a publicly-forwarded same-port tunnel (ssh -g -L, some devcontainer/Codespaces setups) already exposes unauthenticated /api/* — which reaches provider credentials. Widening the Host rule widens that class. A relaxation that ships without an authentication story makes an existing exposure larger.
Existing regression coverage that any change must not break: tests/server-auth.test.ts:582-602 asserts Host: attacker.test:<port> → 403 with cross-origin request blocked. What is missing is a test pinning the port-equality clause specifically.
Proposed hardening plan
Filed as one issue because the items share a threat model; they can land as separate PRs.
1. Decide the trust rule for Host (security review required). Options, with tradeoffs:
- (a) Align Host with Origin — accept any port when the Host is
localhost/127.0.0.1/::1. Smallest change, restores parity with e4e06125b, fixes port-remapped tunnels. Does not fix hostname aliases, and does nothing about the missing loopback auth.
- (b) Explicit allowlist — a config field (e.g.
trustedRequestHosts) listing accepted authorities. Covers aliases too and is opt-in, so the default posture is unchanged; costs discoverability and a config migration.
- (c) Opt-in authentication for loopback binds — allow
OPENCODEX_API_AUTH_TOKEN to be enforced even on loopback, then relax the Host rule behind it. Addresses the actual exposure rather than the symptom; largest change.
(a) and (c) are complementary rather than exclusive.
2. Regression tests for the port-equality clause on one management and one data-plane route, keeping server-auth.test.ts:582-602 green.
3. Base URL reporting. src/server/management/api-access.ts:72-75 honours the request Host only for wildcard binds, so a forwarded proxy reports the remote machine's http://127.0.0.1:<remote-port>/v1; gui/src/pages/api-keys-utils.ts:18-24 hardcodes the same. Even with item 1 fixed, the address the dashboard tells users to copy is wrong under forwarding.
4. Host aliases. Decide explicitly whether myhost.lan-style authorities are in scope. They are rejected today even on a matching port, so item 1(a) alone leaves Tailscale/mDNS/devcontainer hostnames broken.
5. Docs. The "Remote access" section in docs-site/src/content/docs/reference/configuration.md covers 0.0.0.0 + token. Add an SSH port-forwarding recipe, state the same-port requirement (or the new rule), and note the auth posture.
6. OAuth over a tunnel (separate, not caused by the above). src/oauth/chatgpt.ts:9 pins CALLBACK_PORT = 1455 with an exact redirectUri, which disables the random-port fallback (src/oauth/callback-server.ts:126-140). Forwarding 1455 alongside the proxy port satisfies it with no code change, so this is primarily a documentation item; a redirectUri override would be a design question of its own.
Out of scope
Accepting Authorization: Bearer in remote mode. PR #496 was closed for this, and auth-cors.ts:181-185 records the rationale (keeping the two bearer domains unconfusable). Note that Codex CLI does send x-opencodex-api-key in remote mode — src/codex/inject.ts:101-120 injects it into the provider table.
Environment
- Branch
dev @ 5f76d583a
- Measurements taken in-process against
startServer(0) with a loopback bind, varying only the Host header.
Summary
Reaching the proxy through a port-remapping tunnel (
ssh -L 20100:localhost:10100, a devcontainer/Codespaces forward, or any hostname alias) makes the dashboard load but leaves every API call rejected with403. The proxy is healthy; the admission gate rejects the request before routing.The cause is one predicate in
src/server/auth-cors.ts:isAllowedRequestOrigin()early-returnsfalsewhen this is false, regardless of theOriginheader — including when there is none. So this is not a CORS problem: Codex CLI, Claude Code, andcurlare rejected the same way a browser is.Scope, measured. Same-port forwarding works today and is unaffected. Only remapped ports and host aliases break:
/and/healthzpass while everything else fails, which is why the reported symptom is "the dashboard opens but nothing works" rather than a connection error.Workaround (no code change): forward the same port —
ssh -L 10100:localhost:10100 remote.Why this matters
The gate is on the data plane, not just management routes:
src/server/index.ts322(OPTIONS preflight),336(Responses WS upgrade),373/v1/models,443/v1/responses/compact,475/v1/images/*,494/v1/alpha/search,518/v1/responses,558/573/v1/messages*,595/v1/chat/completions,618/646/v1/live. A port-remapped tunnel therefore fails every inference call, not only the dashboard.How the inconsistency arose
c29ee783e(2026-06-27) introduced port-equality on bothisLoopbackRequestHost(Host) andisLoopbackOriginValue(Origin) — a "same origin as the dashboard" trust rule.e4e06125b(2026-07-05, "fix: allow CORS from any loopback origin regardless of port") relaxed the Origin side, reasoning that browser apps on other localhost ports were blocked. The Host side was left untouched.Origin is port-agnostic today; Host is still port-pinned. The relaxation argument from
e4e06125bapplies equally to Host, but was never carried over.Security boundary
.github/CODEOWNERSassigns/src/server/auth-cors.tsto the "Authentication, credentials, and management API" group. PerMAINTAINERS.mdandAGENTS.md, this needs explicit security review — it is not a small predicate tweak.The threat model must be stated before changing anything, because loopback binds have no authentication at all:
isApiAuthRequired = !isLoopbackHostname(config.hostname), soOPENCODEX_API_AUTH_TOKENcannot be enforced while bound to loopback. Today a publicly-forwarded same-port tunnel (ssh -g -L, some devcontainer/Codespaces setups) already exposes unauthenticated/api/*— which reaches provider credentials. Widening the Host rule widens that class. A relaxation that ships without an authentication story makes an existing exposure larger.Existing regression coverage that any change must not break:
tests/server-auth.test.ts:582-602assertsHost: attacker.test:<port>→403withcross-origin request blocked. What is missing is a test pinning the port-equality clause specifically.Proposed hardening plan
Filed as one issue because the items share a threat model; they can land as separate PRs.
1. Decide the trust rule for
Host(security review required). Options, with tradeoffs:localhost/127.0.0.1/::1. Smallest change, restores parity withe4e06125b, fixes port-remapped tunnels. Does not fix hostname aliases, and does nothing about the missing loopback auth.trustedRequestHosts) listing accepted authorities. Covers aliases too and is opt-in, so the default posture is unchanged; costs discoverability and a config migration.OPENCODEX_API_AUTH_TOKENto be enforced even on loopback, then relax the Host rule behind it. Addresses the actual exposure rather than the symptom; largest change.(a) and (c) are complementary rather than exclusive.
2. Regression tests for the port-equality clause on one management and one data-plane route, keeping
server-auth.test.ts:582-602green.3. Base URL reporting.
src/server/management/api-access.ts:72-75honours the request Host only for wildcard binds, so a forwarded proxy reports the remote machine'shttp://127.0.0.1:<remote-port>/v1;gui/src/pages/api-keys-utils.ts:18-24hardcodes the same. Even with item 1 fixed, the address the dashboard tells users to copy is wrong under forwarding.4. Host aliases. Decide explicitly whether
myhost.lan-style authorities are in scope. They are rejected today even on a matching port, so item 1(a) alone leaves Tailscale/mDNS/devcontainer hostnames broken.5. Docs. The "Remote access" section in
docs-site/src/content/docs/reference/configuration.mdcovers0.0.0.0+ token. Add an SSH port-forwarding recipe, state the same-port requirement (or the new rule), and note the auth posture.6. OAuth over a tunnel (separate, not caused by the above).
src/oauth/chatgpt.ts:9pinsCALLBACK_PORT = 1455with an exactredirectUri, which disables the random-port fallback (src/oauth/callback-server.ts:126-140). Forwarding1455alongside the proxy port satisfies it with no code change, so this is primarily a documentation item; aredirectUrioverride would be a design question of its own.Out of scope
Accepting
Authorization: Bearerin remote mode. PR #496 was closed for this, andauth-cors.ts:181-185records the rationale (keeping the two bearer domains unconfusable). Note that Codex CLI does sendx-opencodex-api-keyin remote mode —src/codex/inject.ts:101-120injects it into the provider table.Environment
dev@5f76d583astartServer(0)with a loopback bind, varying only theHostheader.