Skip to content

Commit 9225921

Browse files
ntdatt812decolua
authored andcommitted
fix(security): require proof that x-9r-real-ip came from the socket (GHSA-pjm4-8fpg-f9p6)
x-9r-real-ip and the Host fallback were trusted from client-controlled headers whenever custom-server.js was not in the request path (npm run start, start:bun), letting a remote caller pose as local to skip API key auth and reach LOCAL_ONLY_PATHS (/api/mcp/*, /api/tunnel/enable, /api/auth/reset-password). custom-server.js now generates a per-process secret at boot and stamps it as x-9r-peer-token on every request it sanitizes. hasTrustedPeerHeaders() (src/lib/auth/trustedPeer.js) gates trust in x-9r-real-ip on that secret; otherwise the guard falls back to Host only in development, and fails closed in production. Same gate on loginLimiter.getClientIp() so a spoofed header cannot rotate the login lockout bucket. Also: fix isLoopbackHostname for IPv6 (::1, ::ffff:127.0.0.1) which the old split(":")[0] reduced to empty string; route npm run start / start:bun through custom-server.js (postbuild copies it into .next/standalone, build-cli.js fails without it) so documented deployments keep passwordless local access.
1 parent b04c03c commit 9225921

12 files changed

Lines changed: 403 additions & 23 deletions

‎cli/scripts/build-cli.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,9 @@ function buildCliPackage() {
216216
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
217217
console.log("✅ Copied custom-server.js\n");
218218
} else {
219-
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
219+
console.error("❌ custom-server.js not found — without it no request can be proven local,");
220+
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
221+
process.exit(1);
220222
}
221223

222224
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.

‎custom-server.js‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
const http = require("http");
22
const path = require("path");
3+
const fs = require("fs");
4+
const crypto = require("crypto");
35
const { pathToFileURL } = require("url");
46

57
const origCreate = http.createServer.bind(http);
68

9+
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
10+
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
11+
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
12+
// so the request-detail header sanitizer redacts it too.
13+
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
14+
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
15+
716
let backgroundRefreshStarted = false;
817

918
function startBackgroundTokenRefreshFromCustomServer() {
@@ -57,7 +66,9 @@ http.createServer = (...args) => {
5766
delete req.headers["x-9r-real-ip"];
5867
delete req.headers["x-forwarded-for"];
5968
delete req.headers["x-9r-via-proxy"];
69+
delete req.headers["x-9r-peer-token"];
6070
req.headers["x-9r-real-ip"] = ip;
71+
req.headers["x-9r-peer-token"] = PEER_TOKEN;
6172
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
6273
return handler(req, res);
6374
};
@@ -114,4 +125,15 @@ http.createServer = (...args) => {
114125
return server;
115126
};
116127

117-
if (require.main === module) require("./server.js");
128+
if (require.main === module) {
129+
const standalone = path.join(__dirname, "server.js");
130+
if (fs.existsSync(standalone)) {
131+
require(standalone);
132+
} else {
133+
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
134+
// server in-process, so the wrapper above still sanitizes every request.
135+
const nextBin = require.resolve("next/dist/bin/next");
136+
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
137+
require(nextBin);
138+
}
139+
}

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
"build": "next build --webpack",
1010
"postbuild": "node scripts/copy-standalone-assets.mjs",
1111
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
12-
"start": "next start --port 20127",
12+
"start": "node custom-server.js --port 20127",
1313
"dev:bun": "bun --bun next dev --webpack --port 20127",
1414
"build:bun": "bun --bun next build --webpack",
15-
"start:bun": "bun ./.next/standalone/server.js",
15+
"start:bun": "bun ./.next/standalone/custom-server.js",
1616
"cli:pack": "npm --prefix cli run pack:cli",
1717
"cli:publish": "npm --prefix cli run publish:cli"
1818
},

‎scripts/copy-standalone-assets.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
2929
cpSync(publicSource, publicDestination, { recursive: true, force: true });
3030
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
3131
}
32+
33+
// Without it beside server.js the standalone build serves requests unsanitized.
34+
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
35+
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
36+
if (existsSync(serverWrapperSource)) {
37+
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
38+
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
39+
}
3240
}
3341

3442
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {

‎src/dashboardGuard.js‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
22
import { getSettings, validateApiKey } from "@/lib/localDb";
33
import { getConsistentMachineId } from "@/shared/utils/machineId";
44
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
5+
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
56

67
const CLI_TOKEN_HEADER = "x-9r-cli-token";
78
const CLI_TOKEN_SALT = "9r-cli-auth";
@@ -87,24 +88,40 @@ const LOCAL_ONLY_PATHS = [
8788

8889
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
8990

91+
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
92+
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
93+
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
9094
function isLoopbackHostname(h) {
9195
if (!h) return false;
92-
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
96+
let name = String(h).trim().toLowerCase();
97+
if (name.startsWith("[")) {
98+
const end = name.indexOf("]");
99+
if (end === -1) return false;
100+
name = name.slice(1, end);
101+
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
102+
name = name.slice(0, name.indexOf(":"));
103+
}
104+
if (name.startsWith("::ffff:")) name = name.slice(7);
93105
return LOOPBACK_HOSTS.has(name);
94106
}
95107

108+
function isLoopbackPeer(request) {
109+
if (hasTrustedPeerHeaders(request)) {
110+
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
111+
}
112+
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
113+
// reaches us. Host is spoofable, so this stays confined to development.
114+
if (process.env.NODE_ENV === "development") {
115+
return isLoopbackHostname(request.headers.get("host"));
116+
}
117+
return false;
118+
}
119+
96120
export function isLocalRequest(request) {
97121
// Stamped by custom-server.js when forwarding headers exist: request came through
98122
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
99123
if (request.headers.get("x-9r-via-proxy")) return false;
100-
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
101-
const realIp = request.headers.get("x-9r-real-ip");
102-
if (realIp) {
103-
if (!isLoopbackHostname(realIp)) return false;
104-
} else if (!isLoopbackHostname(request.headers.get("host"))) {
105-
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
106-
return false;
107-
}
124+
if (!isLoopbackPeer(request)) return false;
108125
const origin = request.headers.get("origin");
109126
if (origin) {
110127
try {

‎src/lib/auth/loginLimiter.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// In-memory progressive lockout for dashboard login. Resets on process restart.
2+
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
23

34
const MAX_FAILS_BEFORE_LOCK = 5;
45
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
@@ -46,9 +47,12 @@ export function recordSuccess(ip) {
4647
}
4748

4849
export function getClientIp(request) {
49-
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
50-
const realIp = request.headers.get("x-9r-real-ip");
51-
if (realIp) return realIp;
50+
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
51+
// otherwise a client could rotate the value to escape its own lockout bucket.
52+
if (hasTrustedPeerHeaders(request)) {
53+
const realIp = request.headers.get("x-9r-real-ip");
54+
if (realIp) return realIp;
55+
}
5256
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
5357
if (process.env.TRUST_PROXY === "true") {
5458
const xff = request.headers.get("x-forwarded-for");

‎src/lib/auth/trustedPeer.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
2+
// It proves that by echoing the per-process secret it generated at boot, which a client
3+
// cannot guess. Without the proof the header is just attacker-supplied input.
4+
export function hasTrustedPeerHeaders(request) {
5+
const token = process.env.NINEROUTER_PEER_TOKEN;
6+
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
7+
}

‎src/lib/db/repos/requestDetailsRepo.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ function sanitizeHeaders(headers) {
6868
return sanitized;
6969
}
7070

71+
export const __test__ = { sanitizeHeaders };
72+
7173
function generateDetailId(model) {
7274
const timestamp = new Date().toISOString();
7375
const random = Math.random().toString(36).substring(2, 8);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
2+
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
3+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
4+
import { createRequire } from "node:module";
5+
import http from "node:http";
6+
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
7+
8+
const require = createRequire(import.meta.url);
9+
10+
let server;
11+
let baseUrl;
12+
let seenHeaders;
13+
14+
beforeAll(async () => {
15+
require("../../custom-server.js");
16+
server = http.createServer((req, res) => {
17+
seenHeaders = req.headers;
18+
res.end("ok");
19+
});
20+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
21+
baseUrl = `http://127.0.0.1:${server.address().port}`;
22+
});
23+
24+
afterAll(async () => {
25+
await new Promise((resolve) => server.close(resolve));
26+
});
27+
28+
async function get(headers = {}) {
29+
await fetch(baseUrl, { headers });
30+
return seenHeaders;
31+
}
32+
33+
describe("custom-server peer header sanitizing", () => {
34+
it("generates a peer trust token at boot", () => {
35+
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
36+
});
37+
38+
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
39+
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
40+
41+
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
42+
});
43+
44+
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
45+
const headers = await get();
46+
47+
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
48+
});
49+
50+
it("drops a client-supplied peer trust token", async () => {
51+
const headers = await get({ "x-9r-peer-token": "forged-token" });
52+
53+
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
54+
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
55+
});
56+
57+
it("drops a client-supplied x-9r-via-proxy marker", async () => {
58+
const headers = await get({ "x-9r-via-proxy": "1" });
59+
60+
expect(headers["x-9r-via-proxy"]).toBeUndefined();
61+
});
62+
63+
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
64+
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
65+
66+
expect(headers["x-9r-via-proxy"]).toBe("1");
67+
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
68+
expect(headers["x-forwarded-for"]).toBeUndefined();
69+
});
70+
71+
// chat.js snapshots every client header into the request detail. Anything that grants
72+
// access must not survive into a record the dashboard renders and cloud sync uploads.
73+
it("keeps the peer token out of persisted request details", () => {
74+
const sanitized = requestDetails.sanitizeHeaders({
75+
"x-9r-peer-token": "secret",
76+
"x-9r-cli-token": "secret",
77+
"authorization": "Bearer sk-x",
78+
"x-9r-real-ip": "127.0.0.1",
79+
});
80+
81+
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
82+
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
83+
expect(sanitized["authorization"]).toBeUndefined();
84+
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
85+
});
86+
});

‎tests/unit/dashboard-guard.test.js‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
3535

3636
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
3737

38+
const PEER_TOKEN = "peer-token-fixture";
39+
3840
function request(pathname, headers = {}) {
3941
const normalizedHeaders = new Headers(headers);
4042
return {
@@ -45,24 +47,31 @@ function request(pathname, headers = {}) {
4547
};
4648
}
4749

50+
// A request that actually came through custom-server.js: peer IP stamped from the TCP
51+
// socket and proven by the per-process secret.
52+
function localRequest(pathname, headers = {}) {
53+
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
54+
}
55+
4856
describe("dashboard guard public LLM API access", () => {
4957
beforeEach(() => {
5058
vi.clearAllMocks();
59+
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
5160
mocks.getSettings.mockResolvedValue({ requireLogin: true });
5261
mocks.validateApiKey.mockResolvedValue(false);
5362
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
5463
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
5564
});
5665

5766
it("allows loopback public LLM API without API key", async () => {
58-
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
67+
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
5968

6069
expect(response).toBe(mocks.nextResponse);
6170
expect(mocks.validateApiKey).not.toHaveBeenCalled();
6271
});
6372

6473
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
65-
const response = await proxy(request("/v1/chat/completions", {
74+
const response = await proxy(localRequest("/v1/chat/completions", {
6675
host: "localhost",
6776
"x-9r-real-ip": "10.204.111.34",
6877
}));
@@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
7281
});
7382

7483
it("allows loopback peer IP regardless of Host", async () => {
75-
const response = await proxy(request("/v1/chat/completions", {
84+
const response = await proxy(localRequest("/v1/chat/completions", {
7685
host: "localhost:20128",
7786
"x-9r-real-ip": "127.0.0.1",
7887
}));
@@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
8998
});
9099

91100
it("allows loopback rewritten public LLM API without API key", async () => {
92-
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
101+
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
93102

94103
expect(response).toBe(mocks.nextResponse);
95104
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
191200
describe("dashboard guard local-only access", () => {
192201
beforeEach(() => {
193202
vi.clearAllMocks();
203+
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
194204
mocks.getSettings.mockResolvedValue({ requireLogin: true });
195205
mocks.validateApiKey.mockResolvedValue(false);
196206
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
207217
});
208218

209219
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
210-
const response = await proxy(request("/api/mcp/filesystem/sse", {
220+
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
211221
host: "localhost:20128",
212222
origin: "http://localhost:20128",
213223
}));
@@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
219229
it("allows local-only route on loopback when requireLogin=false", async () => {
220230
mocks.getSettings.mockResolvedValue({ requireLogin: false });
221231

222-
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
232+
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
223233
host: "localhost:20128",
224234
origin: "http://localhost:20128",
225235
}));
@@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
240250
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
241251
mocks.getSettings.mockResolvedValue({ requireLogin: false });
242252

243-
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
253+
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
244254
host: "localhost:20128",
245255
origin: "http://evil.example.com",
246256
}));

0 commit comments

Comments
 (0)