fix: deny-by-default API auth + safe SSE controller

This commit is contained in:
decolua
2026-05-15 12:41:52 +07:00
parent 6493391415
commit bb86808582
3 changed files with 53 additions and 28 deletions
@@ -36,8 +36,14 @@ export async function POST(request) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
let closed = false;
const send = (event, data) => { const send = (event, data) => {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
}; };
try { try {
@@ -52,7 +58,7 @@ export async function POST(request) {
: error.message; : error.message;
send("error", { error: msg }); send("error", { error: msg });
} finally { } finally {
controller.close(); if (!closed) { try { controller.close(); } catch {} }
} }
}, },
}); });
+44 -12
View File
@@ -18,29 +18,60 @@ async function hasValidCliToken(request) {
return token === await getCliToken(); 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",
"/api/settings/require-login",
];
// Public top-level prefixes (LLM API endpoints with their own API key auth).
const PUBLIC_PREFIXES = ["/v1", "/v1beta"];
// Always require JWT token regardless of requireLogin setting // Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [ const ALWAYS_PROTECTED = [
"/api/shutdown", "/api/shutdown",
"/api/settings/database", "/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 // Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [ const PROTECTED_API_PATHS = [
"/api/settings", "/api/settings",
"/api/keys", "/api/keys",
"/api/providers/client", "/api/providers",
"/api/provider-nodes/validate", "/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/cli-tools",
"/api/mcp", "/api/mcp",
"/api/translator", "/api/translator",
"/api/tunnel", "/api/tunnel",
]; ];
// Routes that spawn child processes — restrict to localhost regardless of auth. // Routes that spawn child processes or read host secrets — restrict to localhost.
const LOCAL_ONLY_PATHS = [ const LOCAL_ONLY_PATHS = [
"/api/cli-tools/cowork-settings", "/api/cli-tools/cowork-settings",
"/api/mcp/", "/api/mcp/",
"/api/tunnel/tailscale-install", "/api/tunnel/tailscale-install",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
]; ];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
@@ -85,13 +116,18 @@ async function isAuthenticated(request) {
return false; return false;
} }
function isPublicApi(pathname) {
if (PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`))) return true;
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
}
export async function proxy(request) { export async function proxy(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Local-only gate for spawn-capable routes (CVE GHSA-fhh6-4qxv-rpqj). // Local-only gate for spawn-capable / host-secret routes.
if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) { if (LOCAL_ONLY_PATHS.some((p) => pathname.startsWith(p))) {
if (!isLocalRequest(request)) { if (!isLocalRequest(request)) {
return NextResponse.json({ error: "Local only: MCP requires localhost access" }, { status: 403 }); return NextResponse.json({ error: "Local only: loopback access required" }, { status: 403 });
} }
} }
@@ -102,9 +138,9 @@ export async function proxy(request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
// Protect sensitive API endpoints (allow CLI token, JWT, or requireLogin=false) // Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
if (PROTECTED_API_PATHS.some((p) => pathname.startsWith(p))) { if (pathname.startsWith("/api/")) {
if (pathname === "/api/settings/require-login") return NextResponse.next(); if (isPublicApi(pathname)) return NextResponse.next();
if (await hasValidCliToken(request) || await isAuthenticated(request)) if (await hasValidCliToken(request) || await isAuthenticated(request))
return NextResponse.next(); return NextResponse.next();
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -158,7 +194,3 @@ export async function proxy(request) {
return NextResponse.next(); return NextResponse.next();
} }
export const config = {
matcher: ["/", "/dashboard/:path*"],
};
+1 -14
View File
@@ -1,18 +1,5 @@
export { proxy } from "./dashboardGuard"; export { proxy } from "./dashboardGuard";
export const config = { export const config = {
matcher: [ matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"],
"/",
"/dashboard/:path*",
"/api/shutdown",
"/api/settings/:path*",
"/api/keys",
"/api/keys/:path*",
"/api/providers/client",
"/api/provider-nodes/validate",
"/api/cli-tools/:path*",
"/api/mcp/:path*",
"/api/translator/:path*",
"/api/tunnel/:path*",
],
}; };