Skip to content

Commit cab3336

Browse files
lsr911claude
andcommitted
fix(codex): guard loopback WS classification with isIP to prevent DNS hostname bypass
The remote-auth boundary in assertCodexAppServerConnectionSecurity uses isLoopbackWebSocketUrl to classify WS transports as local-loopback (auth-free) vs remote (requires authToken/Authorization). Before this fix, host.startsWith("127.") matched DNS hostnames like 127.evil.com, letting a remote WebSocket endpoint skip authentication entirely. Add isIP(host)===4 guard so only literal IPv4 loopback addresses qualify. Same canonical pattern as extensions/slack/src/monitor/relay-source.ts:271. Co-Authored-By: Claude <[email protected]>
1 parent e976e35 commit cab3336

2 files changed

Lines changed: 115 additions & 1 deletion

File tree

extensions/codex/src/app-server/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Codex helper module supports config behavior.
22
import { createHash, createHmac, randomBytes } from "node:crypto";
33
import { readFileSync } from "node:fs";
4+
import { isIP } from "node:net";
45
import { homedir as readHomeDir, hostname as readHostName } from "node:os";
56
import path from "node:path";
67
import {
@@ -1344,7 +1345,10 @@ function isLoopbackWebSocketUrl(value: string): boolean {
13441345
host === "127.0.0.1" ||
13451346
host === "::1" ||
13461347
host === "[::1]" ||
1347-
host.startsWith("127.")
1348+
// Guard with isIP so DNS hostnames like 127.evil.com don't bypass
1349+
// the remote-auth boundary — only literal IPv4 loopback addresses
1350+
// starting with 127. qualify.
1351+
(isIP(host) === 4 && host.startsWith("127."))
13481352
);
13491353
}
13501354

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Covers DNS hostname bypass of the remote-auth WS loopback boundary.
2+
// Fix: guard `host.startsWith("127.")` with `isIP(host) === 4` so
3+
// hostnames like `127.evil.com` don't skip authToken/Authorization.
4+
import { describe, expect, it } from "vitest";
5+
6+
// Reach into config.ts for the internal helper; test through its canonical
7+
// consumer `assertCodexAppServerConnectionSecurity` where possible.
8+
const { assertCodexAppServerConnectionSecurity } = await import("./config.js");
9+
10+
describe("isLoopbackWebSocketUrl (via assertCodexAppServerConnectionSecurity)", () => {
11+
it("rejects DNS hostnames that start with 127.", () => {
12+
// DNS hostnames must NOT bypass the remote-auth boundary.
13+
// Before the fix, host.startsWith("127.") classified these as loopback.
14+
expect(() =>
15+
assertCodexAppServerConnectionSecurity({
16+
transport: "websocket",
17+
url: "ws://127.evil.com/app",
18+
authToken: undefined,
19+
headers: {},
20+
}),
21+
).toThrow(/remote Codex app-server WebSocket URLs require/);
22+
expect(() =>
23+
assertCodexAppServerConnectionSecurity({
24+
transport: "websocket",
25+
url: "wss://127.example.com/codex",
26+
authToken: undefined,
27+
headers: {},
28+
}),
29+
).toThrow(/remote Codex app-server WebSocket URLs require/);
30+
});
31+
32+
it("rejects DNS hostnames with a 127-like subdomain", () => {
33+
expect(() =>
34+
assertCodexAppServerConnectionSecurity({
35+
transport: "websocket",
36+
url: "wss://ws.127.com/codex",
37+
authToken: undefined,
38+
headers: {},
39+
}),
40+
).toThrow(/remote Codex app-server WebSocket URLs require/);
41+
});
42+
43+
it("still classifies literal 127/8 IPv4 as loopback", () => {
44+
// Valid IPv4 loopback addresses must still skip auth.
45+
expect(() =>
46+
assertCodexAppServerConnectionSecurity({
47+
transport: "websocket",
48+
url: "ws://127.0.0.1:3333/app",
49+
authToken: undefined,
50+
headers: {},
51+
}),
52+
).not.toThrow();
53+
expect(() =>
54+
assertCodexAppServerConnectionSecurity({
55+
transport: "websocket",
56+
url: "ws://127.255.255.254/codex",
57+
authToken: undefined,
58+
headers: {},
59+
}),
60+
).not.toThrow();
61+
});
62+
63+
it("still classifies localhost and ::1 as loopback", () => {
64+
expect(() =>
65+
assertCodexAppServerConnectionSecurity({
66+
transport: "websocket",
67+
url: "ws://localhost:3333/app",
68+
authToken: undefined,
69+
headers: {},
70+
}),
71+
).not.toThrow();
72+
expect(() =>
73+
assertCodexAppServerConnectionSecurity({
74+
transport: "websocket",
75+
url: "ws://[::1]:3333/app",
76+
authToken: undefined,
77+
headers: {},
78+
}),
79+
).not.toThrow();
80+
});
81+
82+
it("does not throw for non-WS transports (they are always local-loopback)", () => {
83+
// Non-websocket transports bypass the WS URL check entirely.
84+
expect(() =>
85+
assertCodexAppServerConnectionSecurity({
86+
transport: "stdio",
87+
authToken: undefined,
88+
headers: {},
89+
}),
90+
).not.toThrow();
91+
});
92+
93+
it("does not throw for remote WS with valid auth", () => {
94+
expect(() =>
95+
assertCodexAppServerConnectionSecurity({
96+
transport: "websocket",
97+
url: "wss://remote.example.com/codex",
98+
authToken: "secret",
99+
headers: {},
100+
}),
101+
).not.toThrow();
102+
expect(() =>
103+
assertCodexAppServerConnectionSecurity({
104+
transport: "websocket",
105+
url: "ws://remote.example.com/codex",
106+
headers: { authorization: "Bearer token" },
107+
}),
108+
).not.toThrow();
109+
});
110+
});

0 commit comments

Comments
 (0)