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

View file

@ -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 {} }
}
},
});

View file

@ -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*"],
};

View file

@ -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).*)"],
};