Root cause
Authentication & access control introduced
feat: add round-robin routing strategy
How AI contributed: Direct introductionPublished Jul 15, 2026 in
decolua/9routerAll 3 findings in this repository
Mass assignment (CWE-915) in PATCH /api/settings (src/app/api/settings/route.js): the endpoint writes the entire request body to persistent settings via updateSettings(body) with no field whitelist (src/lib/localDb.js updateSettings = {...db.data.settings, ...updates}). An authenticated user can set security-critical keys, notably requireLogin=false, disabling authentication for the whole application and exposing protected routes such as /api/keys and /api/providers to unauthenticated access (authorization downgrade). Only password/mitmSudoEncrypted are stripped (added 2026-06-20 in 090886ce, after the 0.5.4 release), so requireLogin remains settable at HEAD.
Root cause
feat: add round-robin routing strategy
Fix
fix: deny-by-default API auth + safe SSE controller
Fix by decolua · no AI marker found
Case facts
@@ -1,16 +1,16 @@-import { getProviderConnections, validateApiKey, updateProviderConnection } from "@/lib/localDb";+import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb"; import { isAccountUnavailable, getUnavailableUntil } from "open-sse/services/accountFallback.js"; import * as log from "../utils/logger.js"; /** * Get provider credentials from localDb- * Filters out unavailable accounts and returns the highest priority available account+ * Filters out unavailable accounts and returns the selected account based on strategy * @param {string} provider - Provider name * @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account) */ export async function getProviderCredentials(provider, excludeConnectionId = null) { const connections = await getProviderConnections({ provider, isActive: true });- + if (connections.length === 0) { log.warn("AUTH", `No credentials for ${provider}`); return null;@@ -28,7 +28,26 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul return null; } - const connection = availableConnections[0];+ const settings = await getSettings();+ const strategy = settings.fallbackStrategy || "fill-first";++ let connection;+ if (strategy === "round-robin") {+ // Sort by lastUsed (nulls first) to pick the least recently used+ const sorted = [...availableConnections].sort((a, b) => {+ if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);+ if (!a.lastUsedAt) return -1;+ if (!b.lastUsedAt) return 1;+ return new Date(a.lastUsedAt) - new Date(b.lastUsedAt);+ });+ connection = sorted[0];++ // Update lastUsedAt asynchronously+ updateProviderConnection(connection.id, { lastUsedAt: new Date().toISOString() }).catch(() => {});+ } else {+ // Default: fill-first (already sorted by priority in getProviderConnections)+ connection = availableConnections[0];+ } return { apiKey: connection.apiKey,@@ -1,11 +1,42 @@ "use client"; +import { useState, useEffect } from "react"; import { Card, Button, Badge, Toggle } from "@/shared/components"; import { useTheme } from "@/shared/hooks/useTheme"; import { APP_CONFIG } from "@/shared/constants/config"; export default function ProfilePage() { const { theme, setTheme, isDark } = useTheme();+ const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });+ const [loading, setLoading] = useState(true);++ useEffect(() => {+ fetch("/api/settings")+ .then((res) => res.json())+ .then((data) => {+ setSettings(data);+ setLoading(false);+ })+ .catch((err) => {+ console.error("Failed to fetch settings:", err);+ setLoading(false);+ });+ }, []);++ const updateFallbackStrategy = async (strategy) => {+ try {+ const res = await fetch("/api/settings", {+ method: "PATCH",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ fallbackStrategy: strategy }),+ });+ if (res.ok) {+ setSettings(prev => ({ ...prev, fallbackStrategy: strategy }));+ }+ } catch (err) {+ console.error("Failed to update settings:", err);+ }+ }; return ( <div className="max-w-2xl mx-auto">@@ -28,6 +59,31 @@ export default function ProfilePage() { </div> </Card> + {/* Routing Preferences */}+ <Card>+ <h3 className="text-lg font-semibold mb-4">Routing Strategy</h3>+ <div className="flex flex-col gap-4">+ <div className="flex items-center justify-between">+ <div>+ <p className="font-medium">Round Robin</p>+ <p className="text-sm text-text-muted">+ Cycle through accounts to distribute load+ </p>+ </div>+ <Toggle+ checked={settings.fallbackStrategy === "round-robin"}+ onChange={() => updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")}+ disabled={loading}+ />+ </div>+ <p className="text-xs text-text-muted italic pt-2 border-t border-border/50">+ {settings.fallbackStrategy === "round-robin"+ ? "Currently distributing requests across all available accounts."+ : "Currently using accounts in priority order (Fill First)."}+ </p>+ </div>+ </Card>+ {/* Theme Preferences */} <Card> <h3 className="text-lg font-semibold mb-4">Appearance</h3>@@ -1,5 +1,5 @@ import { NextResponse } from "next/server";-import { getSettings } from "@/lib/localDb";+import { getSettings, updateSettings } from "@/lib/localDb"; export async function GET() { try {@@ -10,3 +10,14 @@ export async function GET() { return NextResponse.json({ error: error.message }, { status: 500 }); } }++export async function PATCH(request) {+ try {+ const body = await request.json();+ const settings = await updateSettings(body);+ return NextResponse.json(settings);+ } catch (error) {+ console.log("Error updating settings:", error);+ return NextResponse.json({ error: error.message }, { status: 500 });+ }+}@@ -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).*)"], };@@ -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)@@ -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 {} } } }, });AI-assisted change 642faa9c82bf21a15051cfaf1e12d1412c7e2cb5ce1c99672563bccbb8187686 · Fix 1f03f70b1f500588b4b27c285407130ba14b05eb94c506a4556fd8cdcfd9d87a