From bb86808582067e4fc6f004508a919efb9970d1d5 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 15 May 2026 12:41:52 +0700 Subject: [PATCH] fix: deny-by-default API auth + safe SSE controller --- src/app/api/tunnel/tailscale-install/route.js | 10 +++- src/dashboardGuard.js | 56 +++++++++++++++---- src/proxy.js | 15 +---- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/app/api/tunnel/tailscale-install/route.js b/src/app/api/tunnel/tailscale-install/route.js index e16e687..93353e1 100644 --- a/src/app/api/tunnel/tailscale-install/route.js +++ b/src/app/api/tunnel/tailscale-install/route.js @@ -36,8 +36,14 @@ export async function POST(request) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { + let closed = false; 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 { @@ -52,7 +58,7 @@ export async function POST(request) { : error.message; send("error", { error: msg }); } finally { - controller.close(); + if (!closed) { try { controller.close(); } catch {} } } }, }); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 4f7f0ef..6fd85aa 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -18,29 +18,60 @@ async function hasValidCliToken(request) { 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 const ALWAYS_PROTECTED = [ "/api/shutdown", "/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 const PROTECTED_API_PATHS = [ "/api/settings", "/api/keys", - "/api/providers/client", - "/api/provider-nodes/validate", + "/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 — restrict to localhost regardless of auth. +// Routes that spawn child processes or read host secrets — restrict to localhost. const LOCAL_ONLY_PATHS = [ "/api/cli-tools/cowork-settings", "/api/mcp/", "/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"]); @@ -85,13 +116,18 @@ async function isAuthenticated(request) { 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) { 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 (!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 }); } - // Protect sensitive API endpoints (allow CLI token, JWT, or requireLogin=false) - if (PROTECTED_API_PATHS.some((p) => pathname.startsWith(p))) { - if (pathname === "/api/settings/require-login") return NextResponse.next(); + // 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 }); @@ -158,7 +194,3 @@ export async function proxy(request) { return NextResponse.next(); } - -export const config = { - matcher: ["/", "/dashboard/:path*"], -}; diff --git a/src/proxy.js b/src/proxy.js index e92e5fd..7d0b50b 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,18 +1,5 @@ export { proxy } from "./dashboardGuard"; export const config = { - matcher: [ - "/", - "/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*", - ], + matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"], };