From b5edd4acfcbebd8b77dfba7f747f80c5bf8b652d Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Rahil Date: Tue, 7 Jul 2026 20:14:14 +0900 Subject: [PATCH 1/3] fix(proxy): scope auth token cookie path to SERVER_ROOT_PATH When SERVER_ROOT_PATH is set, the login token cookie was written with the default path of "/", so deployments served under different root paths on the same host (for example a.com and a.com/prefix) overwrote each other's auth cookie and bounced users to /sso/key/generate. Set the cookie path to the normalized server root path so cookies stay isolated per deployment. --- litellm/proxy/management_endpoints/ui_sso.py | 2 ++ litellm/proxy/proxy_server.py | 1 + litellm/proxy/utils.py | 14 +++++++++++++ tests/test_litellm/proxy/test_proxy_utils.py | 22 +++++++++++++++++++- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 1feefa5725d..5caa9c66274 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -119,6 +119,7 @@ from litellm.proxy.management_endpoints.types import ( from litellm.proxy.utils import ( PrismaClient, ProxyLogging, + get_cookie_path_from_server_root_path, get_custom_url, get_server_root_path, ) @@ -2796,6 +2797,7 @@ def set_session_token_cookie(response: Response, request: Request, jwt_token: st response.set_cookie( key="token", value=jwt_token, + path=get_cookie_path_from_server_root_path(), secure=IPAddressUtils.is_request_https(request), httponly=False, samesite="lax", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27132c90e05..a52e763ea14 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -645,6 +645,7 @@ from litellm.proxy.utils import ( _is_valid_team_configs, evict_config_param, get_config_param, + get_cookie_path_from_server_root_path, get_custom_url, get_error_message_str, get_server_root_path, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index cab2bd6d9db..0e199c389d1 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -7219,6 +7219,20 @@ def get_server_root_path() -> str: return os.getenv("SERVER_ROOT_PATH", "") +def get_cookie_path_from_server_root_path() -> str: + """ + Cookie `path` scoped to SERVER_ROOT_PATH. + + Ensures auth cookies for deployments served under different root paths + (e.g. `a.com` vs `a.com/prefix`) do not overwrite each other. Defaults to + "/" when SERVER_ROOT_PATH is unset. + """ + root_path = get_server_root_path() + if not root_path or root_path == "/": + return "/" + return "/" + root_path.strip("/") + + def normalize_route_for_root_path(route: str) -> str | None: """Strip SERVER_ROOT_PATH prefix. Returns de-prefixed route, or None if route is not under root path.""" root_path: Final = get_server_root_path() diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index dcaad968663..b5c15f3c5de 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -13,7 +13,7 @@ from litellm.types.guardrails import GuardrailEventHooks from unittest.mock import MagicMock, patch -from litellm.proxy.utils import get_custom_url, join_paths +from litellm.proxy.utils import get_cookie_path_from_server_root_path, get_custom_url, join_paths def test_get_custom_url(monkeypatch): @@ -22,6 +22,26 @@ def test_get_custom_url(monkeypatch): assert custom_url == "http://0.0.0.0:4000/litellm/ui/" +@pytest.mark.parametrize( + "server_root_path, expected", + [ + (None, "/"), + ("", "/"), + ("/", "/"), + ("/litellm", "/litellm"), + ("litellm", "/litellm"), + ("/litellm/", "/litellm"), + ("/team/a", "/team/a"), + ], +) +def test_get_cookie_path_from_server_root_path(monkeypatch, server_root_path, expected): + if server_root_path is None: + monkeypatch.delenv("SERVER_ROOT_PATH", raising=False) + else: + monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path) + assert get_cookie_path_from_server_root_path() == expected + + def test_proxy_only_error_true_for_llm_route(): proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) assert proxy_logging_obj._is_proxy_only_llm_api_error( From da46d7e237356d08c67e84f8d2aebc352d9cfb41 Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Rahil Date: Tue, 7 Jul 2026 20:41:22 +0900 Subject: [PATCH 2/3] fix(ui): clear auth token cookie at server root path on logout The server-set token cookie is now scoped to SERVER_ROOT_PATH, so logout must also clear it at that path. clearTokenCookies only cleared "/", the UI path, and the current directory, leaving the server-root-scoped cookie in place; a logged-out user's session could be restored on path-mounted deployments. Derive the server root path from the UI cookie path and clear the token cookie there as well. --- ui/litellm-dashboard/src/utils/cookieUtils.test.ts | 14 ++++++++++++++ ui/litellm-dashboard/src/utils/cookieUtils.ts | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts index a38b96e74c2..a6f39b96996 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts @@ -128,6 +128,20 @@ describe("cookieUtils", () => { vi.restoreAllMocks(); }); + it("should clear token cookie at the server root path when deployed under SERVER_ROOT_PATH", () => { + const originalLocation = window.location; + vi.stubGlobal("location", { ...originalLocation, pathname: "/litellm/ui/" }); + + const cookieSpy = vi.spyOn(document, "cookie", "set"); + + clearTokenCookies(); + + expect(cookieSpy).toHaveBeenCalledWith(expect.stringContaining("path=/litellm;")); + + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + it("should clear sessionStorage token", () => { sessionStorage.setItem("token", "stored-token"); clearTokenCookies(); diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index 66eb807ed2d..d4b2c6a2490 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -38,6 +38,13 @@ export function clearTokenCookies() { const uiCookiePath = getUiCookiePath(); const paths = ["/", uiCookiePath]; + // Clear at the server root path (e.g. "/litellm") too, since the server-set + // auth cookie is scoped there when SERVER_ROOT_PATH is configured. + const serverRootPath = uiCookiePath.replace(/\/ui$/, ""); + if (serverRootPath && !paths.includes(serverRootPath)) { + paths.push(serverRootPath); + } + // Add the current path directory if it's different from root and /ui if (currentPath && currentPath !== "/" && !currentPath.startsWith("/ui")) { const dirPath = currentPath.substring(0, currentPath.lastIndexOf("/") + 1); From 27668d00bd25bde485f2d8b88fb18b7ccb3d2a83 Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Rahil Date: Tue, 7 Jul 2026 20:57:47 +0900 Subject: [PATCH 3/3] fix(ui): derive UI cookie path from the last /ui segment getUiCookiePath matched the first /ui segment, but the UI mounts at the last one (SERVER_ROOT_PATH + /ui). For a root path that itself contains a /ui segment (e.g. SERVER_ROOT_PATH=/foo/ui/bar), it resolved the wrong path, so logout cleared the wrong cookie paths and left the scoped auth cookie in place. Use the last /ui match so both the stored login cookie and the logout clearing target the correct server root path. --- litellm/proxy/proxy_server.py | 1 - ui/litellm-dashboard/src/utils/cookieUtils.test.ts | 14 ++++++++++++++ ui/litellm-dashboard/src/utils/cookieUtils.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a52e763ea14..27132c90e05 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -645,7 +645,6 @@ from litellm.proxy.utils import ( _is_valid_team_configs, evict_config_param, get_config_param, - get_cookie_path_from_server_root_path, get_custom_url, get_error_message_str, get_server_root_path, diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts index a6f39b96996..1810f59f66d 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts @@ -142,6 +142,20 @@ describe("cookieUtils", () => { vi.restoreAllMocks(); }); + it("should clear token cookie at the server root path when the root path contains a /ui segment", () => { + const originalLocation = window.location; + vi.stubGlobal("location", { ...originalLocation, pathname: "/foo/ui/bar/ui/" }); + + const cookieSpy = vi.spyOn(document, "cookie", "set"); + + clearTokenCookies(); + + expect(cookieSpy).toHaveBeenCalledWith(expect.stringContaining("path=/foo/ui/bar;")); + + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + it("should clear sessionStorage token", () => { sessionStorage.setItem("token", "stored-token"); clearTokenCookies(); diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index d4b2c6a2490..f8466257220 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -14,9 +14,12 @@ function getUiCookiePath(): string { if (typeof window === "undefined") return "/ui"; // Match "/ui" only as a full path segment (followed by "/" or end of string) // to avoid false matches like "/my-ui-tool/login" → "/my-ui". - const match = window.location.pathname.match(/\/ui(?=\/|$)/); - if (match && match.index !== undefined) { - return window.location.pathname.substring(0, match.index + 3); + // The UI mounts at the last "/ui" segment (SERVER_ROOT_PATH + "/ui"), so use the + // last match to stay correct when the root path itself contains a "/ui" segment. + const matches = [...window.location.pathname.matchAll(/\/ui(?=\/|$)/g)]; + const lastMatch = matches[matches.length - 1]; + if (lastMatch && lastMatch.index !== undefined) { + return window.location.pathname.substring(0, lastMatch.index + 3); } return "/ui"; }