diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 413db9e9552..bb520c0f094 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1377,10 +1377,15 @@ try: # equivalents so logo/asset requests include the root path prefix. # Some JS bundles emit absolute paths like "/ui/assets/logos/" which # resolve incorrectly when the app is served under a sub-path. + # Both double-quoted and single-quoted variants are handled. modified_content = modified_content.replace( '"/ui/assets/', f'"{server_root_path}/ui/assets/', ) + modified_content = modified_content.replace( + "'/ui/assets/", + f"'{server_root_path}/ui/assets/", + ) # Replace the /.well-known/litellm-ui-config with the server root path modified_content = modified_content.replace( diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ec98cfd4d1e..66124eb4498 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -4,6 +4,7 @@ import hashlib import inspect import json import os +import re import smtplib import sys import threading @@ -5396,14 +5397,27 @@ def get_proxy_base_url() -> Optional[str]: return os.getenv("PROXY_BASE_URL") +_SERVER_ROOT_PATH_PATTERN = re.compile(r"^(/[a-zA-Z0-9_-]+)*$") + + def get_server_root_path() -> str: """ Get the server root path from the environment variables. - If SERVER_ROOT_PATH is set, return it. - Otherwise, default to "/". + + Raises ValueError on startup if the value contains characters that could + be injected into served static files (e.g. script tags). """ - return os.getenv("SERVER_ROOT_PATH", "") + value = os.getenv("SERVER_ROOT_PATH", "") + if value and not _SERVER_ROOT_PATH_PATTERN.match(value): + raise ValueError( + f"Invalid SERVER_ROOT_PATH {value!r}: must be empty or match " + r"^(/[a-zA-Z0-9_-]+)*$ (e.g. '/myapp' or '/myapp/v1'). " + "Characters outside this set could be injected into served UI assets." + ) + return value def normalize_route_for_root_path(route: str) -> Optional[str]: diff --git a/tests/test_litellm/proxy/test_server_root_path_ui_assets.py b/tests/test_litellm/proxy/test_server_root_path_ui_assets.py index 235f460e28e..58be6b22bcf 100644 --- a/tests/test_litellm/proxy/test_server_root_path_ui_assets.py +++ b/tests/test_litellm/proxy/test_server_root_path_ui_assets.py @@ -29,6 +29,7 @@ def _apply_server_root_path_replacements( content = f.read() modified = content.replace(litellm_asset_prefix, server_root_path) modified = modified.replace('"/ui/assets/', f'"{server_root_path}/ui/assets/') + modified = modified.replace("'/ui/assets/", f"'{server_root_path}/ui/assets/") modified = modified.replace( "/litellm/.well-known/litellm-ui-config", f"{server_root_path}/.well-known/litellm-ui-config", @@ -117,3 +118,67 @@ def test_binary_files_are_skipped(tmp_path): _apply_server_root_path_replacements(str(ui_path), "/root") assert png_file.read_bytes() == b"\x89PNG\r\n\x1a\n" + + +def test_single_quoted_ui_asset_paths_rewritten(tmp_path): + """ + Single-quoted '/ui/assets/' paths must also be rewritten. + Some HTML/CSS files use single quotes for attribute values. + """ + ui_path = tmp_path / "ui" + ui_path.mkdir() + + js_file = ui_path / "bundle.js" + js_file.write_text("let eq='/ui/assets/logos/',r={}") + + server_root_path = "/myapp" + _apply_server_root_path_replacements(str(ui_path), server_root_path) + + result = js_file.read_text() + assert f"'{server_root_path}/ui/assets/logos/'" in result + assert "'/ui/assets/logos/'" not in result + + +def test_invalid_server_root_path_raises_value_error(): + """ + get_server_root_path() must raise ValueError for values that could + be injected into served JS/HTML files (XSS prevention). + """ + import os + from unittest.mock import patch + + from litellm.proxy.utils import get_server_root_path + + malicious_paths = [ + '">', + "/myapp/../../etc/passwd", + "/myapp with spaces", + "/myapp?query=1", + ] + for path in malicious_paths: + with patch.dict(os.environ, {"SERVER_ROOT_PATH": path}): + try: + result = get_server_root_path() + assert False, f"Expected ValueError for path {path!r}, got {result!r}" + except ValueError: + pass # expected + + +def test_valid_server_root_path_accepted(): + """Valid SERVER_ROOT_PATH values must be accepted without error.""" + import os + from unittest.mock import patch + + from litellm.proxy.utils import get_server_root_path + + valid_paths = [ + "", + "/myapp", + "/myapp/v1", + "/web-llmgateway/v1", + "/a-b_c", + ] + for path in valid_paths: + with patch.dict(os.environ, {"SERVER_ROOT_PATH": path}): + result = get_server_root_path() + assert result == path