Skip to content

Commit e2da6f6

Browse files
committed
fix(server): treat forwarded loopback ports as loopback
isLoopbackRequestHost() required the Host header's port to equal the proxy's own port, so a proxy reached through `ssh -L 20100:localhost:10100` arrived as `Host: localhost:20100` and was refused. That gate sits in front of the whole data plane — /v1/models, /v1/responses, /v1/messages, /v1/chat/completions, /v1/live and the Responses WebSocket upgrade — not just CORS, and it fires with no Origin header at all, so Codex CLI, Claude Code and curl all saw a proxy that looked completely dead rather than one with a CORS problem. Loopback is a trust boundary by hostname, not by port. The sibling isLoopbackOriginValue() dropped its own port check for exactly this reason in e4e0612. Port equality was never the rebinding defense either: a rebinding browser connects to the real port and sends it verbatim, so the hostname check is what rejected it before and still does. Verified against 40 Host forms — localhost.attacker.com, 127.0.0.1.nip.io, localtest.me, 0.0.0.0, 127.0.0.2, [::ffff:127.0.0.1] and a Cyrillic homograph all stay refused. Also accepts the FQDN form `localhost.`, which curl sends verbatim and which was refused for the same class of reason. The predicate had no test coverage at all. The new file pins both directions, including a characterization test for the pre-existing fail-open on an unparseable Host so tightening it later needs a deliberate failing test. Docs: forwarding is now documented, with the caveats that a forwarded loopback is unauthenticated (ssh -g / container publishing expose it), that the client base URL must be set by hand, and that provider OAuth login needs its own forward.
1 parent 0a356d7 commit e2da6f6

3 files changed

Lines changed: 147 additions & 3 deletions

File tree

docs-site/src/content/docs/reference/configuration.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,39 @@ Binding to `0.0.0.0` exposes your proxy — and all configured provider credenti
167167
network. Only do this on trusted networks, and always set a strong `OPENCODEX_API_AUTH_TOKEN`.
168168
:::
169169

170+
### SSH port forwarding
171+
172+
You do not need a non-loopback bind to use a proxy on another machine. Forward the port
173+
over SSH and leave `hostname` at its `127.0.0.1` default:
174+
175+
```bash
176+
ssh -L 20100:localhost:10100 you@remote
177+
```
178+
179+
The local port does not have to match the remote one. opencodex treats any request whose
180+
`Host` resolves to `localhost`, `127.0.0.1`, or `::1` as loopback regardless of port, so
181+
`http://localhost:20100/v1` works for Codex CLI, Claude Code, the dashboard, and `curl`.
182+
183+
Point the client at the forwarded port yourself — `ocx` only ever writes `127.0.0.1` with the
184+
local default port into client config, so a forwarded setup needs the base URL set by hand.
185+
186+
Provider OAuth login is the one flow a single forward does not cover: the login callback
187+
listens on a fixed port on the *remote* machine. Either run `ocx login <provider>` there, or
188+
forward that port too:
189+
190+
```bash
191+
ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote
192+
```
193+
194+
:::caution[Forwarded loopback is unauthenticated]
195+
A loopback bind has no token authentication — that is what makes the default setup usable
196+
without configuration. A plain `ssh -L` keeps the listener on your own loopback interface, so
197+
nothing else can reach it. But `ssh -g -L`, container port publishing, and some devcontainer
198+
or Codespaces forwarding modes bind the *client* side to `0.0.0.0`, which exposes both the
199+
management API and the data plane to that network with no credential. Use `-L` without `-g`,
200+
or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10100`).
201+
:::
202+
170203
## Providers (`OcxProviderConfig`)
171204

172205
| Field | Type | Meaning |

src/server/auth-cors.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
1818

1919
let _corsOrigin = "http://localhost:10100";
2020
export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
21+
/** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */
2122
export function configuredPort(): string {
2223
try { return new URL(_corsOrigin).port; } catch { return "10100"; }
2324
}
@@ -35,8 +36,18 @@ export function parseHttpHost(value: string | null): { hostname: string; port: s
3536
export function isLoopbackRequestHost(value: string | null): boolean {
3637
const parsed = parseHttpHost(value);
3738
if (!parsed) return true;
38-
if (!isLoopbackHostname(parsed.hostname)) return false;
39-
return parsed.port === "" || parsed.port === configuredPort();
39+
// Loopback is a trust boundary by hostname, not by port. `ssh -L 20100:localhost:10100`
40+
// legitimately arrives as `Host: localhost:20100`, and refusing it took the whole /v1/*
41+
// data plane down with it, not just CORS. The sibling isLoopbackOriginValue() dropped its
42+
// own port check for the same reason in e4e06125b ("same-trust-boundary"). Port equality
43+
// was never the rebinding defense: a rebinding browser connects to the real port and sends
44+
// it verbatim, so the hostname check below is what rejected it then and now.
45+
//
46+
// Scope of that guarantee: it holds for Hosts `parseHttpHost` can parse. An unparseable
47+
// Host still returns true above — pre-existing behavior, not browser-reachable (a browser
48+
// composes Host from its own connection), and pinned by a characterization test in
49+
// tests/server-loopback-host-gate.test.ts. Tightening it is separate work.
50+
return isLoopbackHostname(parsed.hostname);
4051
}
4152

4253
export function isLoopbackOriginValue(value: string): boolean {
@@ -115,7 +126,9 @@ export function configuredApiAuthToken(_config: OcxConfig): string | undefined {
115126
}
116127

117128
export function isLoopbackHostname(hostname: string | undefined): boolean {
118-
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
129+
// A fully-qualified "localhost." is the same host as "localhost": curl and some clients
130+
// send the trailing dot verbatim, and refusing it 403s a legitimate loopback caller.
131+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");
119132
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
120133
}
121134

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { isAllowedRequestOrigin, isLoopbackRequestHost } from "../src/server/auth-cors";
3+
import type { OcxConfig } from "../src/types";
4+
5+
// A loopback bind: isApiAuthRequired() is false, so admission runs through the
6+
// Host/Origin branch these tests exercise.
7+
const loopbackConfig = { hostname: "127.0.0.1" } as OcxConfig;
8+
9+
function request(host: string, origin?: string): Request {
10+
const headers: Record<string, string> = { Host: host };
11+
if (origin) headers.Origin = origin;
12+
return new Request("http://x/v1/models", { headers });
13+
}
14+
15+
describe("isLoopbackRequestHost", () => {
16+
test("a forwarded loopback port is still loopback (ssh -L 20100:localhost:10100)", () => {
17+
// Regression: coupling loopback identity to port equality 403'd the entire /v1/*
18+
// data plane whenever the client reached the proxy through a forwarded port.
19+
expect(isLoopbackRequestHost("localhost:20100")).toBe(true);
20+
expect(isLoopbackRequestHost("127.0.0.1:20100")).toBe(true);
21+
expect(isLoopbackRequestHost("[::1]:20100")).toBe(true);
22+
});
23+
24+
test("the proxy's own port, a bare host, and a missing Host stay allowed", () => {
25+
expect(isLoopbackRequestHost("localhost:10100")).toBe(true);
26+
expect(isLoopbackRequestHost("localhost")).toBe(true);
27+
expect(isLoopbackRequestHost("127.0.0.1")).toBe(true);
28+
expect(isLoopbackRequestHost(null)).toBe(true);
29+
});
30+
31+
test("a non-loopback hostname is refused on every port", () => {
32+
// The hostname check is the real DNS-rebinding boundary; it must not depend on
33+
// which port the attacker names.
34+
expect(isLoopbackRequestHost("attacker.test:10100")).toBe(false);
35+
expect(isLoopbackRequestHost("attacker.test:20100")).toBe(false);
36+
expect(isLoopbackRequestHost("192.168.1.5:10100")).toBe(false);
37+
expect(isLoopbackRequestHost("example.com")).toBe(false);
38+
});
39+
40+
test("names that merely look loopback are refused", () => {
41+
// These are the DNS-rebinding shapes that matter: a hostname the attacker controls
42+
// which either embeds "localhost"/"127.0.0.1" as a label or resolves to loopback.
43+
expect(isLoopbackRequestHost("localhost.attacker.com")).toBe(false);
44+
expect(isLoopbackRequestHost("127.0.0.1.attacker.com")).toBe(false);
45+
expect(isLoopbackRequestHost("127.0.0.1.nip.io")).toBe(false);
46+
expect(isLoopbackRequestHost("localtest.me")).toBe(false);
47+
// Cyrillic "о" in "lоcalhost" — the URL parser punycodes it, so it must not match.
48+
expect(isLoopbackRequestHost("l\u043Ecalhost:20100")).toBe(false);
49+
// Not loopback despite the shape.
50+
expect(isLoopbackRequestHost("0.0.0.0:20100")).toBe(false);
51+
expect(isLoopbackRequestHost("127.0.0.2:20100")).toBe(false);
52+
});
53+
54+
test("alternative spellings of real loopback are accepted (URL normalization)", () => {
55+
expect(isLoopbackRequestHost("127.1:20100")).toBe(true);
56+
expect(isLoopbackRequestHost("2130706433:20100")).toBe(true);
57+
expect(isLoopbackRequestHost("LOCALHOST:20100")).toBe(true);
58+
// `curl http://localhost.:20100/` sends the FQDN form; it is the same host.
59+
expect(isLoopbackRequestHost("localhost.:20100")).toBe(true);
60+
expect(isLoopbackRequestHost("localhost.")).toBe(true);
61+
});
62+
63+
test("characterization: an unparseable Host still fails open", () => {
64+
// Pre-existing behavior of `if (!parsed) return true`, unchanged by the port-check
65+
// removal and not browser-reachable (a browser composes Host from its own connection).
66+
// Pinned here so tightening it is a deliberate change with a failing test, not a
67+
// silent drift. See the scope note in isLoopbackRequestHost.
68+
expect(isLoopbackRequestHost("attacker.test:99999")).toBe(true);
69+
expect(isLoopbackRequestHost("attacker test:80")).toBe(true);
70+
});
71+
});
72+
73+
describe("isAllowedRequestOrigin over a forwarded port", () => {
74+
test("a CLI with no Origin reaches the data plane through the forward", () => {
75+
// Codex CLI, Claude Code and curl send no Origin at all, so this path — not CORS —
76+
// is what made a forwarded proxy look completely dead.
77+
expect(isAllowedRequestOrigin(request("localhost:20100"), loopbackConfig)).toBe(true);
78+
});
79+
80+
test("a browser Origin on the forwarded port is allowed", () => {
81+
expect(
82+
isAllowedRequestOrigin(request("localhost:20100", "http://localhost:20100"), loopbackConfig),
83+
).toBe(true);
84+
});
85+
86+
test("a non-loopback Host is still refused with and without an Origin", () => {
87+
expect(isAllowedRequestOrigin(request("attacker.test:20100"), loopbackConfig)).toBe(false);
88+
expect(
89+
isAllowedRequestOrigin(request("attacker.test:20100", "http://attacker.test:20100"), loopbackConfig),
90+
).toBe(false);
91+
});
92+
93+
test("a loopback Host with a non-loopback Origin is still refused", () => {
94+
expect(
95+
isAllowedRequestOrigin(request("localhost:20100", "http://attacker.test"), loopbackConfig),
96+
).toBe(false);
97+
});
98+
});

0 commit comments

Comments
 (0)