From 92259214db71319aad25cb8b9f23c8f3d79dcaa2 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Fri, 14 Aug 2026 16:32:40 +0700 Subject: [PATCH] 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. --- cli/scripts/build-cli.js | 4 +- custom-server.js | 24 +- package.json | 4 +- scripts/copy-standalone-assets.mjs | 8 + src/dashboardGuard.js | 35 ++- src/lib/auth/loginLimiter.js | 10 +- src/lib/auth/trustedPeer.js | 7 + src/lib/db/repos/requestDetailsRepo.js | 2 + tests/unit/custom-server-peer-headers.test.js | 86 +++++++ tests/unit/dashboard-guard.test.js | 24 +- .../local-request-peer-trust-3294.test.js | 211 ++++++++++++++++++ tests/unit/standalone-assets.test.js | 11 + 12 files changed, 403 insertions(+), 23 deletions(-) create mode 100644 src/lib/auth/trustedPeer.js create mode 100644 tests/unit/custom-server-peer-headers.test.js create mode 100644 tests/unit/local-request-peer-trust-3294.test.js diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index d4e4d279..2c23d1e7 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -216,7 +216,9 @@ function buildCliPackage() { fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); console.log("✅ Copied custom-server.js\n"); } else { - console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); + console.error("❌ custom-server.js not found — without it no request can be proven local,"); + console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1."); + process.exit(1); } // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. diff --git a/custom-server.js b/custom-server.js index cf824ba4..ba092136 100644 --- a/custom-server.js +++ b/custom-server.js @@ -1,9 +1,18 @@ const http = require("http"); const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); const { pathToFileURL } = require("url"); const origCreate = http.createServer.bind(http); +// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client. +// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching +// header even though the env var is inherited by child processes. Named like x-9r-cli-token +// so the request-detail header sanitizer redacts it too. +const PEER_TOKEN = crypto.randomBytes(24).toString("hex"); +process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; + let backgroundRefreshStarted = false; function startBackgroundTokenRefreshFromCustomServer() { @@ -57,7 +66,9 @@ http.createServer = (...args) => { delete req.headers["x-9r-real-ip"]; delete req.headers["x-forwarded-for"]; delete req.headers["x-9r-via-proxy"]; + delete req.headers["x-9r-peer-token"]; req.headers["x-9r-real-ip"] = ip; + req.headers["x-9r-peer-token"] = PEER_TOKEN; if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; return handler(req, res); }; @@ -114,4 +125,15 @@ http.createServer = (...args) => { return server; }; -if (require.main === module) require("./server.js"); +if (require.main === module) { + const standalone = path.join(__dirname, "server.js"); + if (fs.existsSync(standalone)) { + require(standalone); + } else { + // Repo checkout has no standalone build next to us. `next start` builds its HTTP + // server in-process, so the wrapper above still sanitizes every request. + const nextBin = require.resolve("next/dist/bin/next"); + process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)]; + require(nextBin); + } +} diff --git a/package.json b/package.json index 542a70b6..39d71447 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "build": "next build --webpack", "postbuild": "node scripts/copy-standalone-assets.mjs", "postbuild:bun": "node scripts/copy-standalone-assets.mjs", - "start": "next start --port 20127", + "start": "node custom-server.js --port 20127", "dev:bun": "bun --bun next dev --webpack --port 20127", "build:bun": "bun --bun next build --webpack", - "start:bun": "bun ./.next/standalone/server.js", + "start:bun": "bun ./.next/standalone/custom-server.js", "cli:pack": "npm --prefix cli run pack:cli", "cli:publish": "npm --prefix cli run publish:cli" }, diff --git a/scripts/copy-standalone-assets.mjs b/scripts/copy-standalone-assets.mjs index bfaf6e0d..c0bc0a5a 100644 --- a/scripts/copy-standalone-assets.mjs +++ b/scripts/copy-standalone-assets.mjs @@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr cpSync(publicSource, publicDestination, { recursive: true, force: true }); console.log(`[standalone-assets] Copied public assets to ${publicDestination}`); } + + // Without it beside server.js the standalone build serves requests unsanitized. + const serverWrapperSource = resolve(projectRoot, "custom-server.js"); + const serverWrapperDestination = resolve(standaloneDir, "custom-server.js"); + if (existsSync(serverWrapperSource)) { + cpSync(serverWrapperSource, serverWrapperDestination, { force: true }); + console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`); + } } if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) { diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3f3e6c29..1c6a4483 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, validateApiKey } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; +import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer"; const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_SALT = "9r-cli-auth"; @@ -87,24 +88,40 @@ const LOCAL_ONLY_PATHS = [ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); +// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first +// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack +// listener handing back ::ffff:127.0.0.1 would not read as loopback. function isLoopbackHostname(h) { if (!h) return false; - const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase(); + let name = String(h).trim().toLowerCase(); + if (name.startsWith("[")) { + const end = name.indexOf("]"); + if (end === -1) return false; + name = name.slice(1, end); + } else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) { + name = name.slice(0, name.indexOf(":")); + } + if (name.startsWith("::ffff:")) name = name.slice(7); return LOOPBACK_HOSTS.has(name); } +function isLoopbackPeer(request) { + if (hasTrustedPeerHeaders(request)) { + return isLoopbackHostname(request.headers.get("x-9r-real-ip")); + } + // Bare `next dev` forks its server, so the wrapper never loads and no peer address + // reaches us. Host is spoofable, so this stays confined to development. + if (process.env.NODE_ENV === "development") { + return isLoopbackHostname(request.headers.get("host")); + } + return false; +} + export function isLocalRequest(request) { // Stamped by custom-server.js when forwarding headers exist: request came through // a reverse proxy, so the loopback socket is the proxy hop, not the end-user. if (request.headers.get("x-9r-via-proxy")) return false; - // Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local". - const realIp = request.headers.get("x-9r-real-ip"); - if (realIp) { - if (!isLoopbackHostname(realIp)) return false; - } else if (!isLoopbackHostname(request.headers.get("host"))) { - // Fallback for bare server.js (dev) without custom-server: legacy Host-based check. - return false; - } + if (!isLoopbackPeer(request)) return false; const origin = request.headers.get("origin"); if (origin) { try { diff --git a/src/lib/auth/loginLimiter.js b/src/lib/auth/loginLimiter.js index 43dec55b..4a1770a6 100644 --- a/src/lib/auth/loginLimiter.js +++ b/src/lib/auth/loginLimiter.js @@ -1,4 +1,5 @@ // In-memory progressive lockout for dashboard login. Resets on process restart. +import { hasTrustedPeerHeaders } from "./trustedPeer.js"; const MAX_FAILS_BEFORE_LOCK = 5; 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) { } export function getClientIp(request) { - // Trusted: set from TCP socket by custom-server.js (client cannot spoof). - const realIp = request.headers.get("x-9r-real-ip"); - if (realIp) return realIp; + // Trusted only when custom-server.js proves it stamped the header from the TCP socket; + // otherwise a client could rotate the value to escape its own lockout bucket. + if (hasTrustedPeerHeaders(request)) { + const realIp = request.headers.get("x-9r-real-ip"); + if (realIp) return realIp; + } // Behind a trusted reverse proxy that overwrites XFF with the real client IP. if (process.env.TRUST_PROXY === "true") { const xff = request.headers.get("x-forwarded-for"); diff --git a/src/lib/auth/trustedPeer.js b/src/lib/auth/trustedPeer.js new file mode 100644 index 00000000..123b1587 --- /dev/null +++ b/src/lib/auth/trustedPeer.js @@ -0,0 +1,7 @@ +// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket. +// It proves that by echoing the per-process secret it generated at boot, which a client +// cannot guess. Without the proof the header is just attacker-supplied input. +export function hasTrustedPeerHeaders(request) { + const token = process.env.NINEROUTER_PEER_TOKEN; + return Boolean(token) && request.headers.get("x-9r-peer-token") === token; +} diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index ceed4c01..f7273831 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -68,6 +68,8 @@ function sanitizeHeaders(headers) { return sanitized; } +export const __test__ = { sanitizeHeaders }; + function generateDetailId(model) { const timestamp = new Date().toISOString(); const random = Math.random().toString(36).substring(2, 8); diff --git a/tests/unit/custom-server-peer-headers.test.js b/tests/unit/custom-server-peer-headers.test.js new file mode 100644 index 00000000..7bc3053e --- /dev/null +++ b/tests/unit/custom-server-peer-headers.test.js @@ -0,0 +1,86 @@ +// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real +// HTTP server through it and confirm a client cannot smuggle its own peer headers in. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createRequire } from "node:module"; +import http from "node:http"; +import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js"; + +const require = createRequire(import.meta.url); + +let server; +let baseUrl; +let seenHeaders; + +beforeAll(async () => { + require("../../custom-server.js"); + server = http.createServer((req, res) => { + seenHeaders = req.headers; + res.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +async function get(headers = {}) { + await fetch(baseUrl, { headers }); + return seenHeaders; +} + +describe("custom-server peer header sanitizing", () => { + it("generates a peer trust token at boot", () => { + expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/); + }); + + it("replaces a client-supplied x-9r-real-ip with the socket address", async () => { + const headers = await get({ "x-9r-real-ip": "203.0.113.55" }); + + expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/); + }); + + it("stamps the trust token so downstream can tell the wrapper ran", async () => { + const headers = await get(); + + expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN); + }); + + it("drops a client-supplied peer trust token", async () => { + const headers = await get({ "x-9r-peer-token": "forged-token" }); + + expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN); + expect(headers["x-9r-peer-token"]).not.toBe("forged-token"); + }); + + it("drops a client-supplied x-9r-via-proxy marker", async () => { + const headers = await get({ "x-9r-via-proxy": "1" }); + + expect(headers["x-9r-via-proxy"]).toBeUndefined(); + }); + + it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => { + const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" }); + + expect(headers["x-9r-via-proxy"]).toBe("1"); + expect(headers["x-9r-real-ip"]).toBe("203.0.113.9"); + expect(headers["x-forwarded-for"]).toBeUndefined(); + }); + + // chat.js snapshots every client header into the request detail. Anything that grants + // access must not survive into a record the dashboard renders and cloud sync uploads. + it("keeps the peer token out of persisted request details", () => { + const sanitized = requestDetails.sanitizeHeaders({ + "x-9r-peer-token": "secret", + "x-9r-cli-token": "secret", + "authorization": "Bearer sk-x", + "x-9r-real-ip": "127.0.0.1", + }); + + expect(sanitized["x-9r-peer-token"]).toBeUndefined(); + expect(sanitized["x-9r-cli-token"]).toBeUndefined(); + expect(sanitized["authorization"]).toBeUndefined(); + expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1"); + }); +}); diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 099cbde7..fd30e4b2 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({ const { proxy, __test__ } = await import("../../src/dashboardGuard.js"); +const PEER_TOKEN = "peer-token-fixture"; + function request(pathname, headers = {}) { const normalizedHeaders = new Headers(headers); return { @@ -45,9 +47,16 @@ function request(pathname, headers = {}) { }; } +// A request that actually came through custom-server.js: peer IP stamped from the TCP +// socket and proven by the per-process secret. +function localRequest(pathname, headers = {}) { + return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers }); +} + describe("dashboard guard public LLM API access", () => { beforeEach(() => { vi.clearAllMocks(); + process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; mocks.getSettings.mockResolvedValue({ requireLogin: true }); mocks.validateApiKey.mockResolvedValue(false); mocks.getConsistentMachineId.mockResolvedValue("cli-token"); @@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => { }); it("allows loopback public LLM API without API key", async () => { - const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" })); + const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" })); expect(response).toBe(mocks.nextResponse); expect(mocks.validateApiKey).not.toHaveBeenCalled(); }); it("rejects remote Host-spoof when real peer IP is non-loopback", async () => { - const response = await proxy(request("/v1/chat/completions", { + const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost", "x-9r-real-ip": "10.204.111.34", })); @@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => { }); it("allows loopback peer IP regardless of Host", async () => { - const response = await proxy(request("/v1/chat/completions", { + const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128", "x-9r-real-ip": "127.0.0.1", })); @@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => { }); it("allows loopback rewritten public LLM API without API key", async () => { - const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" })); + const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" })); expect(response).toBe(mocks.nextResponse); expect(mocks.validateApiKey).not.toHaveBeenCalled(); @@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => { describe("dashboard guard local-only access", () => { beforeEach(() => { vi.clearAllMocks(); + process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; mocks.getSettings.mockResolvedValue({ requireLogin: true }); mocks.validateApiKey.mockResolvedValue(false); mocks.getConsistentMachineId.mockResolvedValue("cli-token"); @@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => { }); it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => { - const response = await proxy(request("/api/mcp/filesystem/sse", { + const response = await proxy(localRequest("/api/mcp/filesystem/sse", { host: "localhost:20128", origin: "http://localhost:20128", })); @@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => { it("allows local-only route on loopback when requireLogin=false", async () => { mocks.getSettings.mockResolvedValue({ requireLogin: false }); - const response = await proxy(request("/api/cli-tools/antigravity-mitm", { + const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", { host: "localhost:20128", origin: "http://localhost:20128", })); @@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => { it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => { mocks.getSettings.mockResolvedValue({ requireLogin: false }); - const response = await proxy(request("/api/cli-tools/antigravity-mitm", { + const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", { host: "localhost:20128", origin: "http://evil.example.com", })); diff --git a/tests/unit/local-request-peer-trust-3294.test.js b/tests/unit/local-request-peer-trust-3294.test.js new file mode 100644 index 00000000..7cc72907 --- /dev/null +++ b/tests/unit/local-request-peer-trust-3294.test.js @@ -0,0 +1,211 @@ +// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request +// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to +// be loopback. Host is spoofable the same way, so it cannot be the production fallback. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + nextResponse: Symbol("next"), + jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })), + getSettings: vi.fn(), + validateApiKey: vi.fn(), + getConsistentMachineId: vi.fn(), + verifyDashboardAuthToken: vi.fn(), +})); + +vi.mock("next/server", () => ({ + NextResponse: { + next: vi.fn(() => mocks.nextResponse), + json: mocks.jsonResponse, + redirect: vi.fn((url) => ({ status: 307, url })), + }, +})); + +vi.mock("@/lib/localDb", () => ({ + getSettings: mocks.getSettings, + validateApiKey: mocks.validateApiKey, +})); + +vi.mock("@/shared/utils/machineId", () => ({ + getConsistentMachineId: mocks.getConsistentMachineId, +})); + +vi.mock("@/lib/auth/dashboardSession", () => ({ + verifyDashboardAuthToken: mocks.verifyDashboardAuthToken, +})); + +const { proxy } = await import("../../src/dashboardGuard.js"); +const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js"); + +const PEER_TOKEN = "peer-token-fixture"; + +function request(pathname, headers = {}) { + return { + nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams }, + headers: new Headers(headers), + cookies: { get: vi.fn(() => undefined) }, + url: `http://localhost${pathname}`, + }; +} + +const originalNodeEnv = process.env.NODE_ENV; + +describe("peer header trust", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; + process.env.NODE_ENV = "production"; + mocks.getSettings.mockResolvedValue({ requireLogin: true }); + mocks.validateApiKey.mockResolvedValue(false); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + mocks.verifyDashboardAuthToken.mockResolvedValue(false); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + delete process.env.NINEROUTER_PEER_TOKEN; + }); + + it("rejects a spoofed loopback peer IP that carries no trust proof", async () => { + const response = await proxy(request("/api/v1/models", { + host: "172.18.192.1:20140", + "x-9r-real-ip": "127.0.0.1", + })); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("API key required for remote API access"); + }); + + it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => { + const response = await proxy(request("/api/v1/models", { + host: "172.18.192.1:20140", + "x-9r-real-ip": "127.0.0.1", + "x-9r-peer-token": "guessed-token", + })); + + expect(response.status).toBe(401); + }); + + it("rejects a spoofed loopback Host in production", async () => { + const response = await proxy(request("/api/v1/models", { host: "localhost" })); + + expect(response.status).toBe(401); + }); + + it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => { + delete process.env.NINEROUTER_PEER_TOKEN; + + const response = await proxy(request("/api/v1/models", { + host: "172.18.192.1:20140", + "x-9r-real-ip": "127.0.0.1", + "x-9r-peer-token": "any-token", + })); + + expect(response.status).toBe(401); + }); + + it("keeps serving a genuinely local request stamped by the wrapper", async () => { + const response = await proxy(request("/api/v1/models", { + host: "localhost:20128", + "x-9r-real-ip": "127.0.0.1", + "x-9r-peer-token": PEER_TOKEN, + })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); + }); + + // A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old + // split-on-first-colon check reduced to "". + it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])( + "treats %s as a loopback peer", + async (peerIp) => { + const response = await proxy(request("/api/v1/models", { + host: "localhost:20128", + "x-9r-real-ip": peerIp, + "x-9r-peer-token": PEER_TOKEN, + })); + + expect(response).toBe(mocks.nextResponse); + } + ); + + it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])( + "refuses %s as a peer", + async (peerIp) => { + const response = await proxy(request("/api/v1/models", { + host: "localhost:20128", + "x-9r-real-ip": peerIp, + "x-9r-peer-token": PEER_TOKEN, + })); + + expect(response.status).toBe(401); + } + ); + + it("still refuses a stamped non-loopback peer IP", async () => { + const response = await proxy(request("/api/v1/models", { + host: "localhost:20128", + "x-9r-real-ip": "10.204.111.34", + "x-9r-peer-token": PEER_TOKEN, + })); + + expect(response.status).toBe(401); + }); + + it("blocks spoofed local-only routes that would otherwise spawn processes", async () => { + mocks.getSettings.mockResolvedValue({ requireLogin: false }); + + const response = await proxy(request("/api/mcp/filesystem/sse", { + host: "172.18.192.1:20140", + "x-9r-real-ip": "127.0.0.1", + })); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Local only: CLI token required"); + }); + + it("accepts the legacy Host fallback only in development", async () => { + process.env.NODE_ENV = "development"; + + const response = await proxy(request("/api/v1/models", { host: "localhost:20127" })); + + expect(response).toBe(mocks.nextResponse); + }); +}); + +describe("login limiter client IP", () => { + beforeEach(() => { + process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; + delete process.env.TRUST_PROXY; + }); + + afterEach(() => { + delete process.env.NINEROUTER_PEER_TOKEN; + delete process.env.TRUST_PROXY; + }); + + it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => { + const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" })); + const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" })); + + expect(first).toBe("unknown"); + expect(second).toBe("unknown"); + }); + + it("keys on the stamped peer IP when the wrapper proved it", () => { + const ip = getClientIp(request("/api/auth/login", { + "x-9r-real-ip": "203.0.113.9", + "x-9r-peer-token": PEER_TOKEN, + })); + + expect(ip).toBe("203.0.113.9"); + }); + + it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => { + process.env.TRUST_PROXY = "true"; + + const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" })); + + expect(ip).toBe("198.51.100.7"); + }); +}); diff --git a/tests/unit/standalone-assets.test.js b/tests/unit/standalone-assets.test.js index 94951325..61a00820 100644 --- a/tests/unit/standalone-assets.test.js +++ b/tests/unit/standalone-assets.test.js @@ -37,6 +37,17 @@ describe("standalone build assets", () => { .toBe("static asset"); }); + // Without the wrapper beside server.js nothing can prove a request is local. + it("copies the request-sanitizing server wrapper into the standalone output", () => { + const projectRoot = createBuildFixture(".next"); + writeFileSync(join(projectRoot, "custom-server.js"), "wrapper"); + + copyStandaloneAssets({ projectRoot, distDir: ".next" }); + + expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8")) + .toBe("wrapper"); + }); + it("does not modify workspace-traced CLI builds", () => { const projectRoot = createBuildFixture(".next-cli-build"); const previousMode = process.env.NEXT_TRACING_ROOT_MODE;