mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: deny-by-default API auth + safe SSE controller
This commit is contained in:
@@ -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
@@ -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
@@ -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*",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user