Skip to content

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

Closed

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.

Activity

  1. github-actions commented on Jul 27, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: unknown.

  2. lidge-jun commented on Jul 28, 2026

    @lidge-jun
    OwnerAuthor

    Status update: items 1(a) and 2 are merged; 3, 4, 5, 6 remain open

    Leaving this open, because only part of the plan in this issue has shipped.

    Merged — e2da6f6df ("fix(server): treat forwarded loopback ports as loopback"), merged via #573 (3a2b2ea8c) and present on dev:

    • Item 1(a) — src/server/auth-cors.ts:37-51 no longer compares the Host port. isLoopbackRequestHost() now returns isLoopbackHostname(parsed.hostname) alone, which is the same trust rule isLoopbackOriginValue() has used since e4e06125b. This closes the parity gap you traced back to c29ee783e.
    • Item 2 — tests/server-loopback-host-gate.test.ts:16-26 pins the port-equality clause specifically: localhost:20100, 127.0.0.1:20100, and [::1]:20100 are all admitted, a same-port Host stays admitted, and the non-loopback rejection in tests/server-auth.test.ts:582-602 stays green.

    So the REMAP | Host=localhost:29999 -> 403 rows from your measurement table are fixed. Port-remapping tunnels (ssh -L 20100:localhost:10100, devcontainer/Codespaces forwards) now reach the data plane.

    Still open, and the reason this issue stays open:

    • Item 4 (host aliases) — your ALIAS | Host=myhost.lan:56030 -> 403 row still reproduces. auth-cors.ts:51 admits a Host only when the hostname itself is loopback, so Tailscale/mDNS/devcontainer hostnames remain rejected. As you wrote, whether those are in scope is an explicit decision, not an implementation detail.
    • Item 1(b) / 1(c) — no trustedRequestHosts allowlist and no opt-in authentication for loopback binds. The exposure you described (isApiAuthRequired = !isLoopbackHostname(config.hostname)) is unchanged.
    • Item 3 — src/server/management/api-access.ts:72-75 and gui/src/pages/api-keys-utils.ts:18-24 still report the wrong base URL under forwarding.
    • Item 5 — the "Remote access" section in docs-site/src/content/docs/reference/configuration.md still has no SSH port-forwarding recipe.
    • Item 6 — src/oauth/chatgpt.ts:9 still pins CALLBACK_PORT = 1455.

    Whether to split the remainder into separate issues or track them here is a maintainer call, as is the trust-rule decision itself: src/server/auth-cors.ts is assigned to the authentication group in .github/CODEOWNERS and needs explicit security review under MAINTAINERS.md.

  3. lidge-jun commented on Jul 31, 2026

    @lidge-jun
    OwnerAuthor

    The reported failure is already fixed on dev, but the issue is not fully closed — so a status note rather than a closure.

    Port remapping works now. isLoopbackRequestHost keys trust on the loopback hostname, not the port (src/server/auth-cors.ts:37-51), so ssh -L 20100:localhost:10100 arriving as Host: localhost:20100 is accepted. tests/server-loopback-host-gate.test.ts:16-22 pins it. The comment there records why the port check was never the rebinding defense: a rebinding browser connects to the real port and sends it verbatim, so the hostname check is what rejected it then and now.

    The alias case is still rejected, deliberately. A tunnel presenting Host: myhost.lan remains 403 (auth-cors.ts:51), and I am not loosening that as a follow-up here.

    The reason is that on a loopback bind the data plane intentionally does not require auth (auth-cors.ts:191-193, :250-258). Accepting arbitrary aliases would mean a DNS-rebinding hostname, or an exposed ssh -g / container forward, could drive the proxy's upstream credentials. The keys are not disclosed, but the inference capability they buy is — which is most of the value.

    A safe version exists, and it is two changes rather than one: an explicit canonical-host allowlist paired with an opt-in "require data-plane auth on loopback" mode, refusing startup if that mode has no token configured. That widens which Host values are accepted while narrowing overall request admission. It is a real feature with its own review, not a one-line relaxation.

    Leaving open for that. If your setup is the port-remapped tunnel from the original report, it should already work on current dev — worth confirming.

  4. lidge-jun commented on Jul 31, 2026

    @lidge-jun
    OwnerAuthor

    Fixed on dev (verified at bf9bc1ac8) by #573.

    The port check that caused this is gone. isLoopbackRequestHost() in src/server/auth-cors.ts:37-51 now decides by hostname alone, and the code says why:

    Loopback is a trust boundary by hostname, not by port. ssh -L 20100:localhost:10100 legitimately arrives as Host: localhost:20100, and refusing it took the whole /v1/* data plane down with it, not just CORS.

    That is exactly your report: the dashboard loaded while every API call 403'd. A port-remapped tunnel now passes.

    One thing I want to be precise about, because it came up while re-checking this. Non-loopback host aliases — myhost.lan and similar — are still rejected at auth-cors.ts:85. That is a different request from the one filed here, and it is a trust-boundary change that needs maintainer security review rather than a quiet widening. If you need alias hosts, please open a separate issue describing the deployment; it should be decided on its own terms.

    Closing on the reported failure mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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