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.
This commit is contained in:
committed by
decolua
parent
b04c03c6b5
commit
92259214db
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
7
src/lib/auth/trustedPeer.js
Normal file
7
src/lib/auth/trustedPeer.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
86
tests/unit/custom-server-peer-headers.test.js
Normal file
86
tests/unit/custom-server-peer-headers.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user