From 5e1c1261368e06dced1cbc650684561b2c8844db Mon Sep 17 00:00:00 2001 From: blu1606 Date: Mon, 18 May 2026 12:46:45 +0700 Subject: [PATCH] fix(security): harden public API and local-only access gates --- src/dashboardGuard.js | 51 +++++++-- tests/unit/dashboard-guard.test.js | 167 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 tests/unit/dashboard-guard.test.js diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 02a6006..4cac10f 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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(); diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js new file mode 100644 index 0000000..55784fc --- /dev/null +++ b/tests/unit/dashboard-guard.test.js @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } 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, __test__ } = await import("../../src/dashboardGuard.js"); + +function request(pathname, headers = {}) { + const normalizedHeaders = new Headers(headers); + return { + nextUrl: { pathname }, + headers: normalizedHeaders, + cookies: { get: vi.fn(() => undefined) }, + url: `http://localhost${pathname}`, + }; +} + +describe("dashboard guard public LLM API access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireLogin: true }); + mocks.validateApiKey.mockResolvedValue(false); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + mocks.verifyDashboardAuthToken.mockResolvedValue(false); + }); + + it("allows loopback public LLM API without API key", async () => { + const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); + }); + + it("rejects remote rewritten public LLM API without API key", async () => { + const response = await proxy(request("/api/v1/chat/completions", { host: "router.example.com" })); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("API key required for remote 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" })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); + }); + + it("rejects remote beta public LLM API without API key", async () => { + const response = await proxy(request("/v1beta/models", { host: "router.example.com" })); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("API key required for remote API access"); + }); + + it("rejects remote rewritten beta public LLM API without API key", async () => { + const response = await proxy(request("/api/v1beta/models", { host: "router.example.com" })); + + expect(response.status).toBe(401); + expect(response.body.error).toBe("API key required for remote API access"); + }); + + it("allows remote public LLM API with valid bearer API key", async () => { + mocks.validateApiKey.mockResolvedValue(true); + + const response = await proxy(request("/api/v1/chat/completions", { + host: "router.example.com", + authorization: "Bearer sk-valid", + })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); + }); + + it("allows remote public LLM API with valid x-api-key", async () => { + mocks.validateApiKey.mockResolvedValue(true); + + const response = await proxy(request("/v1/web/fetch", { + host: "router.example.com", + "x-api-key": "sk-valid", + })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); + }); + + it("allows remote rewritten beta public LLM API with valid API key", async () => { + mocks.validateApiKey.mockResolvedValue(true); + + const response = await proxy(request("/api/v1beta/models", { + host: "router.example.com", + "x-api-key": "sk-valid", + })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); + }); +}); + +describe("dashboard guard local-only access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireLogin: false }); + mocks.validateApiKey.mockResolvedValue(false); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + mocks.verifyDashboardAuthToken.mockResolvedValue(false); + }); + + it("rejects local-only route with spoofed loopback headers but no CLI token", async () => { + const response = await proxy(request("/api/mcp/filesystem/sse", { + host: "localhost:20128", + origin: "http://localhost:20128", + })); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Local only: CLI token required"); + }); + + it("allows local-only route with valid CLI token", async () => { + const response = await proxy(request("/api/mcp/filesystem/sse", { + host: "router.example.com", + "x-9r-cli-token": "cli-token", + })); + + expect(response).toBe(mocks.nextResponse); + }); +}); + +describe("dashboard guard helpers", () => { + it("extracts bearer API keys before x-api-key", () => { + const apiRequest = request("/v1/chat/completions", { + authorization: "Bearer bearer-key", + "x-api-key": "header-key", + }); + + expect(__test__.extractApiKey(apiRequest)).toBe("bearer-key"); + }); +});