fix(security): harden public API and local-only access gates

This commit is contained in:
blu1606
2026-05-18 15:23:37 +07:00
committed by decolua
parent c5afc6a9f9
commit 5e1c126136
2 changed files with 211 additions and 7 deletions
+44 -7
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { getSettings, validateApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
@@ -32,7 +32,7 @@ const PUBLIC_API_PATHS = [
];
// Public top-level prefixes (LLM API endpoints with their own API key auth).
const PUBLIC_PREFIXES = ["/v1", "/v1beta"];
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta"];
// Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [
@@ -90,8 +90,6 @@ function isLoopbackHostname(h) {
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");
@@ -103,6 +101,32 @@ function isLocalRequest(request) {
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);
return request.headers.get("x-api-key");
}
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) {
return await hasValidCliToken(request);
}
async function hasValidToken(request) {
const token = request.cookies.get("auth_token")?.value;
return await verifyDashboardAuthToken(token);
@@ -125,17 +149,25 @@ async function isAuthenticated(request) {
}
function isPublicApi(pathname) {
if (PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`))) return true;
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 (!isLocalRequest(request)) {
return NextResponse.json({ error: "Local only: loopback access required" }, { status: 403 });
if (!(await canAccessLocalOnlyRoute(request))) {
return NextResponse.json({ error: "Local only: CLI token required" }, { status: 403 });
}
}
@@ -146,6 +178,11 @@ export async function proxy(request) {
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();