import { NextResponse } from "next/server"; import { getSettings, getUserById, validateApiKey } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { getDashboardAuthSession, verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; 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/version", ]; // 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", ]; // User administration is never exposed to normal users, even if dashboard login // is disabled for local single-user deployments. CLI Tools directly read and // mutate the account running 9Router's local CLI configuration, so they are // host administration rather than per-user dashboard preferences. const ADMIN_ONLY_PATHS = [ "/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe", "/api/cli-tools", "/api/media-providers", "/api/proxy-pools", "/api/translator/console-logs", ]; // Dashboard paths requiring an administrator. Combo access is handled by its // owner-scoped API routes and is available to authenticated users. const ADMIN_ONLY_DASHBOARD_PATHS = [ "/dashboard/token-saver", "/dashboard/pxpipe", "/dashboard/cli-tools", "/dashboard/media-providers", "/dashboard/proxy-pools", "/dashboard/console-log", ]; // 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/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"]); function isLoopbackHostname(h) { if (!h) return false; const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase(); return LOOPBACK_HOSTS.has(name); } 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; } 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; if (!(await verifyDashboardAuthToken(token))) return false; const session = await getDashboardAuthSession(token); // Legacy/OIDC sessions do not have a local user record yet. Preserve their // existing behavior; password sessions must reflect account deactivation. if (!session?.userId) return true; const user = await getUserById(String(session.userId)); return !!user?.isActive; } async function getAuthenticatedSession(request) { const token = request.cookies.get("auth_token")?.value; return getDashboardAuthSession(token); } async function isAdmin(request) { const session = await getAuthenticatedSession(request); if (!session?.userId) return false; const user = await getUserById(String(session.userId)); return user?.isActive === true && user.role === "admin"; } // 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, isAdmin, }; export async function proxy(request) { const { pathname } = request.nextUrl; // CLI Tools access the server process's home directory and, in the MITM // case, can change privileged system networking. Do not allow a shared CLI // bearer token to become a remote host-administration credential: only a // local, authenticated administrator may use these endpoints. if (pathname === "/api/cli-tools" || pathname.startsWith("/api/cli-tools/")) { if (!isLocalRequest(request)) { return NextResponse.json({ error: "CLI Tools are available only from the local machine" }, { status: 403 }); } if (!(await isAdmin(request))) { return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); } } // 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 }); } if (ADMIN_ONLY_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) { if (await hasValidCliToken(request) || await isAdmin(request)) return NextResponse.next(); return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); } // 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")) { if (ADMIN_ONLY_DASHBOARD_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) { if (await isAdmin(request)) return NextResponse.next(); return NextResponse.redirect(new URL("/dashboard", request.url)); } 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(); }