fix: deny-by-default API auth + safe SSE controller
This commit is contained in:
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 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 {} }
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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*"],
|
||||
};
|
||||
|
|
|
|||
15
src/proxy.js
15
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).*)"],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue