9router/src/dashboardGuard.js

163 lines
5.2 KiB
JavaScript
Raw Normal View History

import { NextResponse } from "next/server";
2026-04-14 01:51:23 -04:00
import { getSettings } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
2026-05-11 22:43:42 -04:00
import { 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();
}
2026-03-27 00:45:54 -04:00
// Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [
"/api/shutdown",
"/api/settings/database",
];
// Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [
"/api/settings",
"/api/keys",
"/api/providers/client",
"/api/provider-nodes/validate",
2026-05-13 09:35:42 -04:00
"/api/cli-tools",
"/api/mcp",
2026-05-14 22:15:53 -04:00
"/api/translator",
2026-03-27 00:45:54 -04:00
];
2026-05-13 09:35:42 -04:00
// Routes that spawn child processes — restrict to localhost regardless of auth.
const LOCAL_ONLY_PATHS = [
"/api/cli-tools/cowork-settings",
"/api/mcp/",
];
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);
}
// Same-host gate: Host header must be loopback AND (if present) Origin must match.
// Defends against tunnel/LAN access, remote browser CSRF, and cross-site form posts.
function isLocalRequest(request) {
if (!isLoopbackHostname(request.headers.get("host"))) return false;
const origin = request.headers.get("origin");
if (origin) {
try {
if (!isLoopbackHostname(new URL(origin).hostname)) return false;
} catch { return false; }
}
return true;
}
2026-03-27 00:45:54 -04:00
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
2026-05-11 22:43:42 -04:00
return await verifyDashboardAuthToken(token);
2026-03-27 00:45:54 -04:00
}
2026-04-14 01:51:23 -04:00
// Read settings directly from DB to avoid self-fetch deadlock in proxy
async function loadSettings() {
2026-03-27 00:45:54 -04:00
try {
2026-04-14 01:51:23 -04:00
return await getSettings();
2026-03-27 00:45:54 -04:00
} catch {
2026-04-14 01:51:23 -04:00
return null;
2026-03-27 00:45:54 -04:00
}
2026-04-14 01:51:23 -04:00
}
async function isAuthenticated(request) {
if (await hasValidToken(request)) return true;
const settings = await loadSettings();
if (settings && settings.requireLogin === false) return true;
2026-03-27 00:45:54 -04:00
return false;
}
export async function proxy(request) {
const { pathname } = request.nextUrl;
2026-05-13 09:35:42 -04:00
// Local-only gate for spawn-capable routes (CVE GHSA-fhh6-4qxv-rpqj).
if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) {
if (!isLocalRequest(request)) {
return NextResponse.json({ error: "Local only: MCP requires localhost access" }, { status: 403 });
}
}
// Always protected - require valid JWT or local CLI token (machineId-based)
2026-03-27 00:45:54 -04:00
if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) {
if (await hasValidCliToken(request) || await hasValidToken(request))
2026-03-27 00:45:54 -04:00
return NextResponse.next();
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Protect sensitive API endpoints (allow CLI token, JWT, or requireLogin=false)
2026-03-27 00:45:54 -04:00
if (PROTECTED_API_PATHS.some((p) => pathname.startsWith(p))) {
if (pathname === "/api/settings/require-login") return NextResponse.next();
if (await hasValidCliToken(request) || await isAuthenticated(request))
2026-03-27 00:45:54 -04:00
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 {
2026-04-14 01:51:23 -04:00
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) {
2026-05-11 22:43:42 -04:00
if (await verifyDashboardAuthToken(token)) {
return NextResponse.next();
2026-05-11 22:43:42 -04:00
} 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();
}
export const config = {
matcher: ["/", "/dashboard/:path*"],
};