Repository navigation
Port-remapped tunnels are rejected by the loopback Host check: dashboard loads, every API call 403s #570
Description
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 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 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 withe4e06125b, 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_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-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 theHostheader.
Activity
github-actions commentedon Jul 27, 2026
Automated translation bookkeeping — detected language: unknown.
lidge-jun commentedon Jul 28, 2026
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 ondev:src/server/auth-cors.ts:37-51no longer compares the Host port.isLoopbackRequestHost()now returnsisLoopbackHostname(parsed.hostname)alone, which is the same trust ruleisLoopbackOriginValue()has used sincee4e06125b. This closes the parity gap you traced back toc29ee783e.tests/server-loopback-host-gate.test.ts:16-26pins the port-equality clause specifically:localhost:20100,127.0.0.1:20100, and[::1]:20100are all admitted, a same-port Host stays admitted, and the non-loopback rejection intests/server-auth.test.ts:582-602stays green.So the
REMAP | Host=localhost:29999 -> 403rows 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:
ALIAS | Host=myhost.lan:56030 -> 403row still reproduces.auth-cors.ts:51admits 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.trustedRequestHostsallowlist and no opt-in authentication for loopback binds. The exposure you described (isApiAuthRequired = !isLoopbackHostname(config.hostname)) is unchanged.src/server/management/api-access.ts:72-75andgui/src/pages/api-keys-utils.ts:18-24still report the wrong base URL under forwarding.docs-site/src/content/docs/reference/configuration.mdstill has no SSH port-forwarding recipe.src/oauth/chatgpt.ts:9still pinsCALLBACK_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.tsis assigned to the authentication group in.github/CODEOWNERSand needs explicit security review underMAINTAINERS.md.test(usage): give the rolling-file bound test room on Windows (#614)
chore(devlog): advance the plan submodule to 7dffc79
lidge-jun commentedon Jul 31, 2026
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.
isLoopbackRequestHostkeys trust on the loopback hostname, not the port (src/server/auth-cors.ts:37-51), sossh -L 20100:localhost:10100arriving asHost: localhost:20100is accepted.tests/server-loopback-host-gate.test.ts:16-22pins 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.lanremains 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 exposedssh -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
Hostvalues 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.lidge-jun commentedon Jul 31, 2026
Fixed on
dev(verified atbf9bc1ac8) by #573.The port check that caused this is gone.
isLoopbackRequestHost()insrc/server/auth-cors.ts:37-51now decides by hostname alone, and the code says why: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.lanand similar — are still rejected atauth-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.
docs(plan): disposition all 41 open issues against dev head
test(usage): give the rolling-file bound test room on Windows (lidge-…
chore(devlog): advance the plan submodule to 7dffc79
docs(plan): disposition all 41 open issues against dev head