Skip to content

Port-remapped tunnels are rejected by the loopback Host check: dashboard loads, every API call 403s #570

Description

@lidge-jun

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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions