Files
9router/src/dashboardGuard.js
Nguyen Thanh Dat 92259214db 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.
2026-08-14 16:33:58 +07:00

279 lines
9.0 KiB
JavaScript

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";
let cachedCliToken = null;
async function getCliToken() {
if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
return cachedCliToken;
}
async function hasValidCliToken(request) {
const token = request.headers.get(CLI_TOKEN_HEADER);
if (!token) return false;
return token === await getCliToken();
}
// Public API paths — no auth required (LLM API has its own key auth inside handler).
const PUBLIC_API_PATHS = [
"/api/health",
"/api/init",
"/api/locale",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc",
"/api/auth/saml",
"/api/version",
"/api/settings/require-login",
];
// Public top-level prefixes (LLM API endpoints with their own API key auth).
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
// Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [
"/api/shutdown",
"/api/settings/database",
"/api/version/shutdown",
"/api/version/update",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
];
// Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [
"/api/settings",
"/api/keys",
"/api/providers",
"/api/provider-nodes",
"/api/proxy-pools",
"/api/combos",
"/api/models",
"/api/usage",
"/api/oauth",
"/api/cloud",
"/api/media-providers",
"/api/pricing",
"/api/tags",
"/api/cli-tools",
"/api/mcp",
"/api/translator",
"/api/tunnel",
];
// Routes that spawn child processes or read host secrets — restrict to localhost.
const LOCAL_ONLY_PATHS = [
"/api/cli-tools/cowork-settings",
"/api/cli-tools/antigravity-mitm",
"/api/mcp/",
"/api/tunnel/tailscale-install",
"/api/tunnel/tailscale-enable",
"/api/tunnel/tailscale-disable",
"/api/tunnel/tailscale-check",
"/api/tunnel/enable",
"/api/tunnel/disable",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",
"/api/headroom/proxy",
];
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;
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;
if (!isLoopbackPeer(request)) return false;
const origin = request.headers.get("origin");
if (origin) {
try {
if (!isLoopbackHostname(new URL(origin).hostname)) return false;
} catch { return false; }
}
return true;
}
function isPublicLlmApi(pathname) {
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
}
function extractApiKey(request) {
const authHeader = request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
const apiKeyHeader = request.headers.get("x-api-key");
if (apiKeyHeader) return apiKeyHeader;
const googleApiKeyHeader = request.headers.get("x-goog-api-key");
if (googleApiKeyHeader) return googleApiKeyHeader;
return request.nextUrl.searchParams?.get("key") || null;
}
async function hasValidApiKey(request) {
const apiKey = extractApiKey(request);
if (!apiKey) return false;
return await validateApiKey(apiKey);
}
async function canAccessPublicLlmApi(request) {
if (isLocalRequest(request)) return true;
if (await hasValidCliToken(request)) return true;
return await hasValidApiKey(request);
}
async function canAccessLocalOnlyRoute(request) {
if (await hasValidCliToken(request)) return true;
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false)
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
return false;
}
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
return await verifyDashboardAuthToken(token);
}
// Read settings directly from DB to avoid self-fetch deadlock in proxy
async function loadSettings() {
try {
return await getSettings();
} catch {
return null;
}
}
async function isAuthenticated(request) {
if (await hasValidToken(request)) return true;
const settings = await loadSettings();
if (settings && settings.requireLogin === false) return true;
return false;
}
function isPublicApi(pathname) {
if (isPublicLlmApi(pathname)) return true;
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
}
export const __test__ = {
isLocalRequest,
isPublicLlmApi,
extractApiKey,
canAccessPublicLlmApi,
canAccessLocalOnlyRoute,
};
export async function proxy(request) {
const { pathname } = request.nextUrl;
// Local-only gate for spawn-capable / host-secret routes.
if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) {
if (!(await canAccessLocalOnlyRoute(request))) {
return NextResponse.json({ error: "Local only: CLI token required" }, { status: 403 });
}
}
// Always protected - require valid JWT or local CLI token (machineId-based)
if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) {
if (await hasValidCliToken(request) || await hasValidToken(request))
return NextResponse.next();
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (isPublicLlmApi(pathname)) {
if (await canAccessPublicLlmApi(request)) return NextResponse.next();
return NextResponse.json({ error: "API key required for remote API access" }, { status: 401 });
}
// Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
if (pathname.startsWith("/api/")) {
if (isPublicApi(pathname)) return NextResponse.next();
if (await hasValidCliToken(request) || await isAuthenticated(request))
return NextResponse.next();
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Protect all dashboard routes
if (pathname.startsWith("/dashboard")) {
let requireLogin = true;
let tunnelDashboardAccess = true;
try {
const settings = await loadSettings();
if (settings) {
requireLogin = settings.requireLogin !== false;
tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
// Block tunnel/tailscale access if disabled (redirect to login)
if (!tunnelDashboardAccess) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
if ((tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost)) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
}
} catch {
// On error, keep defaults (require login, block tunnel)
}
// If login not required, allow through
if (!requireLogin) return NextResponse.next();
// Verify JWT token
const token = request.cookies.get("auth_token")?.value;
if (token) {
if (await verifyDashboardAuthToken(token)) {
return NextResponse.next();
} else {
return NextResponse.redirect(new URL("/login", request.url));
}
}
return NextResponse.redirect(new URL("/login", request.url));
}
// Redirect / to /dashboard if logged in, or /dashboard if it's the root
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}