diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d303734dffd..140736c49c9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -42,108 +42,31 @@ import { jsonFields } from "./common_components/check_openapi_schema"; import NotificationsManager from "./molecules/notifications_manager"; import type { MCPUserEnvVarsStatus } from "./mcp_tools/types"; import { createApiClient, deriveErrorMessage } from "@/lib/http/client"; -import { resolveApiBase } from "@/lib/http/resolveApiBase"; +import { + proxyBaseUrl, + defaultProxyBaseUrl, + globalLitellmHeaderName, + getProxyBaseUrl, + getGlobalLitellmHeaderName, + getWindowLocation, + updateProxyBaseUrl, +} from "@/lib/http/proxyBase"; export { deriveErrorMessage }; export { ApiError } from "@/lib/http/client"; +export { + proxyBaseUrl, + serverRootPath, + getProxyBaseUrl, + switchToWorkerUrl, + setGlobalLitellmHeaderName, + getGlobalLitellmHeaderName, +} from "@/lib/http/proxyBase"; -const isLocal = process.env.NODE_ENV === "development"; -// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls -// to the backend — use relative URLs (null) so rewrites can intercept them. -const resolveDefaultBase = (fallback: string | null): string | null => - process.env.NEXT_PUBLIC_BASE_URL - ? process.env.NEXT_PUBLIC_BASE_URL - : isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" - ? "http://localhost:4000" - : fallback; -const defaultProxyBaseUrl = resolveDefaultBase(null); -const defaultServerRootPath = "/"; -export let serverRootPath = defaultServerRootPath; -const WORKER_URL_KEY = "litellm_worker_url"; -// If a worker URL is in localStorage, use it as the initial proxyBaseUrl. -// This survives page navigation and the sessionStorage.clear() in user_dashboard. -const _rawWorkerUrl = typeof window !== "undefined" ? window.localStorage.getItem(WORKER_URL_KEY) : null; -// Validate stored worker URL — reject non-HTTP schemes to prevent exfiltration -const _initialWorkerUrl = (() => { - if (!_rawWorkerUrl) return null; - try { - const parsed = new URL(_rawWorkerUrl); - if (parsed.protocol === "http:" || parsed.protocol === "https:") return _rawWorkerUrl; - } catch { - /* invalid URL */ - } - // Invalid URL in storage — clear it - if (typeof window !== "undefined") window.localStorage.removeItem(WORKER_URL_KEY); - return null; -})(); -export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl; -if (isLocal != true) { +if (process.env.NODE_ENV !== "development") { console.log = function () {}; } -const getWindowLocation = () => { - if (typeof window === "undefined") { - return null; - } - return window.location; -}; - -const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { - /** - * Special function for updating the proxy base url. Should only be called by getUiConfig. - */ - // If a worker URL is in localStorage, don't let getUiConfig overwrite it - if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) { - return; - } - proxyBaseUrl = resolveApiBase({ - explicitBase: receivedProxyBaseUrl || resolveDefaultBase(getWindowLocation()?.origin ?? null), - serverRootPath, - }); -}; - -const updateServerRootPath = (receivedServerRootPath: string) => { - serverRootPath = receivedServerRootPath; -}; - -export const getProxyBaseUrl = (): string => { - if (proxyBaseUrl) { - return proxyBaseUrl; - } - const browserLocation = getWindowLocation(); - return browserLocation?.origin ?? ""; -}; - -/** - * Switch API calls to point at a worker (or back to the control plane). - * Persists to localStorage so it survives page navigation and the - * sessionStorage.clear() in user_dashboard. Also updates the module-level - * proxyBaseUrl so in-flight code in this JS execution sees the new value - * immediately. - */ -function isValidHttpUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} - -export function switchToWorkerUrl(workerUrl: string | null): void { - if (workerUrl && !isValidHttpUrl(workerUrl)) { - return; - } - if (typeof window !== "undefined") { - if (workerUrl) { - window.localStorage.setItem(WORKER_URL_KEY, workerUrl); - } else { - window.localStorage.removeItem(WORKER_URL_KEY); - } - } - proxyBaseUrl = workerUrl ?? defaultProxyBaseUrl; -} - const HTTP_REQUEST = { GET: "GET", POST: "POST", @@ -356,21 +279,8 @@ export const getAgentCreateMetadata = async (): Promise => { return jsonData; }; -// Global variable for the header name -let globalLitellmHeaderName: string = "Authorization"; const MCP_AUTH_HEADER: string = "x-mcp-auth"; -// Function to set the global header name -export function setGlobalLitellmHeaderName(headerName: string = "Authorization") { - console.log(`setGlobalLitellmHeaderName: ${headerName}`); - globalLitellmHeaderName = headerName; -} - -// Function to get the global header name -export function getGlobalLitellmHeaderName(): string { - return globalLitellmHeaderName; -} - const apiClient = createApiClient({ getBaseUrl: getProxyBaseUrl, getAuthHeaderName: getGlobalLitellmHeaderName, diff --git a/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts b/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts new file mode 100644 index 00000000000..d6b72da35c7 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getProxyBaseUrl, + switchToWorkerUrl, + getGlobalLitellmHeaderName, + setGlobalLitellmHeaderName, +} from "./proxyBase"; + +const WORKER_URL_KEY = "litellm_worker_url"; + +describe("proxyBase", () => { + beforeEach(() => { + window.localStorage.clear(); + switchToWorkerUrl(null); + setGlobalLitellmHeaderName("Authorization"); + }); + + describe("getProxyBaseUrl", () => { + it("falls back to the window origin when no base is configured", () => { + expect(getProxyBaseUrl()).toBe(window.location.origin); + }); + }); + + describe("switchToWorkerUrl", () => { + it("points the base at a valid worker URL and persists it", () => { + switchToWorkerUrl("https://worker.example.com"); + expect(getProxyBaseUrl()).toBe("https://worker.example.com"); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBe("https://worker.example.com"); + }); + + it("rejects a non-http(s) scheme and leaves the base unchanged", () => { + switchToWorkerUrl("javascript:alert(1)"); + expect(getProxyBaseUrl()).toBe(window.location.origin); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBeNull(); + }); + + it("clears the worker URL and falls back when passed null", () => { + switchToWorkerUrl("https://worker.example.com"); + switchToWorkerUrl(null); + expect(getProxyBaseUrl()).toBe(window.location.origin); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBeNull(); + }); + }); + + describe("global header name", () => { + it("defaults to Authorization and reflects updates", () => { + expect(getGlobalLitellmHeaderName()).toBe("Authorization"); + setGlobalLitellmHeaderName("x-litellm-api-key"); + expect(getGlobalLitellmHeaderName()).toBe("x-litellm-api-key"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/proxyBase.ts b/ui/litellm-dashboard/src/lib/http/proxyBase.ts new file mode 100644 index 00000000000..c1edcb99164 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/proxyBase.ts @@ -0,0 +1,108 @@ +import { resolveApiBase } from "./resolveApiBase"; + +const isLocal = process.env.NODE_ENV === "development"; +// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls +// to the backend — use relative URLs (null) so rewrites can intercept them. +const resolveDefaultBase = (fallback: string | null): string | null => + process.env.NEXT_PUBLIC_BASE_URL + ? process.env.NEXT_PUBLIC_BASE_URL + : isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" + ? "http://localhost:4000" + : fallback; + +export const defaultProxyBaseUrl = resolveDefaultBase(null); +const defaultServerRootPath = "/"; +export let serverRootPath = defaultServerRootPath; + +const WORKER_URL_KEY = "litellm_worker_url"; +// If a worker URL is in localStorage, use it as the initial proxyBaseUrl. +// This survives page navigation and the sessionStorage.clear() in user_dashboard. +const _rawWorkerUrl = typeof window !== "undefined" ? window.localStorage.getItem(WORKER_URL_KEY) : null; +// Validate stored worker URL — reject non-HTTP schemes to prevent exfiltration +const _initialWorkerUrl = (() => { + if (!_rawWorkerUrl) return null; + try { + const parsed = new URL(_rawWorkerUrl); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return _rawWorkerUrl; + } catch { + /* invalid URL */ + } + // Invalid URL in storage — clear it + if (typeof window !== "undefined") window.localStorage.removeItem(WORKER_URL_KEY); + return null; +})(); + +export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl; + +export const getWindowLocation = () => { + if (typeof window === "undefined") { + return null; + } + return window.location; +}; + +export const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { + /** + * Special function for updating the proxy base url. Should only be called by getUiConfig. + */ + // If a worker URL is in localStorage, don't let getUiConfig overwrite it + if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) { + return; + } + proxyBaseUrl = resolveApiBase({ + explicitBase: receivedProxyBaseUrl || resolveDefaultBase(getWindowLocation()?.origin ?? null), + serverRootPath, + }); +}; + +export const getProxyBaseUrl = (): string => { + if (proxyBaseUrl) { + return proxyBaseUrl; + } + const browserLocation = getWindowLocation(); + return browserLocation?.origin ?? ""; +}; + +/** + * Switch API calls to point at a worker (or back to the control plane). + * Persists to localStorage so it survives page navigation and the + * sessionStorage.clear() in user_dashboard. Also updates the module-level + * proxyBaseUrl so in-flight code in this JS execution sees the new value + * immediately. + */ +function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function switchToWorkerUrl(workerUrl: string | null): void { + if (workerUrl && !isValidHttpUrl(workerUrl)) { + return; + } + if (typeof window !== "undefined") { + if (workerUrl) { + window.localStorage.setItem(WORKER_URL_KEY, workerUrl); + } else { + window.localStorage.removeItem(WORKER_URL_KEY); + } + } + proxyBaseUrl = workerUrl ?? defaultProxyBaseUrl; +} + +// Global variable for the header name +export let globalLitellmHeaderName: string = "Authorization"; + +// Function to set the global header name +export function setGlobalLitellmHeaderName(headerName: string = "Authorization") { + console.log(`setGlobalLitellmHeaderName: ${headerName}`); + globalLitellmHeaderName = headerName; +} + +// Function to get the global header name +export function getGlobalLitellmHeaderName(): string { + return globalLitellmHeaderName; +}