diff --git a/litellm/proxy/common_utils/config_includes.py b/litellm/proxy/common_utils/config_includes.py index c1bb5ae952f..eb8922bcd9f 100644 --- a/litellm/proxy/common_utils/config_includes.py +++ b/litellm/proxy/common_utils/config_includes.py @@ -8,6 +8,25 @@ from litellm._logging import verbose_proxy_logger INCLUDE_KEY: Final = "include" +def _path_is_within_directory(path: str, directory: str) -> bool: + """True when ``path`` is ``directory`` itself or a file under it (after abspath).""" + try: + return os.path.commonpath([directory, path]) == directory + except ValueError: + # Different drives on Windows — never treat that as inside the config dir. + return False + + +def _reject_include_outside_config_dir(resolved: str, root_config_path: str, include_file: str) -> str: + config_dir: Final = os.path.abspath(os.path.dirname(root_config_path)) + if _path_is_within_directory(resolved, config_dir): + return resolved + raise ValueError( + f"Config include '{include_file}' resolves to {resolved}, which is outside the config " + f"directory {config_dir}. Include paths must stay under the config directory." + ) + + def resolve_include_file_path(include_file: str, declared_in: str, root_config_path: str) -> str: """ Resolve one `include` entry to the file it names, next to the config that declares it. @@ -15,11 +34,14 @@ def resolve_include_file_path(include_file: str, declared_in: str, root_config_p A config written before nested entries resolved this way can name a file sitting next to the root config instead, so that file is still read, with a warning naming where it was found. When both files exist the one next to the declaring config wins and the other is named in a warning. + + Resolved paths that climb out of the root config directory (``..`` or an absolute path outside + it) are rejected before the file is opened. """ declared_relative: Final = os.path.abspath(os.path.join(os.path.dirname(declared_in), include_file)) root_relative: Final = os.path.abspath(os.path.join(os.path.dirname(root_config_path), include_file)) if root_relative == declared_relative or not os.path.exists(root_relative): - return declared_relative + return _reject_include_outside_config_dir(declared_relative, root_config_path, include_file) if not os.path.exists(declared_relative): verbose_proxy_logger.warning( @@ -29,7 +51,7 @@ def resolve_include_file_path(include_file: str, declared_in: str, root_config_p declared_in, root_relative, ) - return root_relative + return _reject_include_outside_config_dir(root_relative, root_config_path, include_file) verbose_proxy_logger.warning( "Config include '%s' declared in %s matches two files. %s sits next to that config and was read, " @@ -39,7 +61,7 @@ def resolve_include_file_path(include_file: str, declared_in: str, root_config_p declared_relative, root_relative, ) - return declared_relative + return _reject_include_outside_config_dir(declared_relative, root_config_path, include_file) class IncludeResolver(Protocol): diff --git a/tests/unit/proxy/common_utils/test_config_includes.py b/tests/unit/proxy/common_utils/test_config_includes.py new file mode 100644 index 00000000000..affe1770277 --- /dev/null +++ b/tests/unit/proxy/common_utils/test_config_includes.py @@ -0,0 +1,65 @@ +"""Unit tests for litellm.proxy.common_utils.config_includes.""" + +import os + +import pytest + +from litellm.proxy.common_utils.config_includes import resolve_include_file_path + + +def test_resolve_include_rejects_path_outside_config_dir(tmp_path): + """ + Include entries that climb out of the root config directory must be rejected + before the file is opened (regression for #43480). + """ + cfg_dir = tmp_path / "cfg" + cfg_dir.mkdir() + root_config = cfg_dir / "config.yaml" + root_config.write_text("model_list: []\n") + + # Same shape as the issue report: ../../etc/passwd from cfg/config.yaml + with pytest.raises(ValueError, match="outside the config directory"): + resolve_include_file_path("../../etc/passwd", str(root_config), str(root_config)) + + # Absolute path outside the config dir + with pytest.raises(ValueError, match="outside the config directory"): + resolve_include_file_path("/etc/passwd", str(root_config), str(root_config)) + + +def test_resolve_include_allows_file_under_config_dir(tmp_path): + """Sibling and nested includes under the config directory still resolve.""" + cfg_dir = tmp_path / "cfg" + nested = cfg_dir / "nested" + nested.mkdir(parents=True) + root_config = cfg_dir / "config.yaml" + root_config.write_text("model_list: []\n") + sibling = cfg_dir / "models.yaml" + sibling.write_text("model_list: []\n") + nested_include = nested / "extra.yaml" + nested_include.write_text("model_list: []\n") + + assert resolve_include_file_path("models.yaml", str(root_config), str(root_config)) == str( + sibling.resolve() + ) + # Nested config may climb one level with .. and still stay under cfg/ + assert resolve_include_file_path( + "../models.yaml", str(nested_include), str(root_config) + ) == str(sibling.resolve()) + + +def test_resolve_include_rejects_escape_via_legacy_root_fallback(tmp_path): + """ + When the include is missing next to a nested declarer but present next to the + root, the legacy fallback must still refuse a path that left the config dir. + """ + cfg_dir = tmp_path / "cfg" + nested = cfg_dir / "nested" + nested.mkdir(parents=True) + root_config = cfg_dir / "config.yaml" + root_config.write_text("model_list: []\n") + nested_config = nested / "part.yaml" + nested_config.write_text("model_list: []\n") + + # declared next to nested; ../../outside.yaml from nested → tmp_path/outside.yaml + with pytest.raises(ValueError, match="outside the config directory"): + resolve_include_file_path("../../outside.yaml", str(nested_config), str(root_config)) diff --git a/tests/unit/proxy/test_proxy_config_unit_test.py b/tests/unit/proxy/test_proxy_config_unit_test.py index 2181c932586..e0cf89cb706 100644 --- a/tests/unit/proxy/test_proxy_config_unit_test.py +++ b/tests/unit/proxy/test_proxy_config_unit_test.py @@ -158,6 +158,22 @@ async def test_multiple_includes(): assert config["litellm_settings"]["callbacks"] == ["prometheus"] + +@pytest.mark.asyncio +async def test_process_includes_rejects_escaping_include(tmp_path): + """ProxyConfig._process_includes must fail closed on escaping include paths.""" + cfg_dir = tmp_path / "cfg" + cfg_dir.mkdir() + outside = tmp_path / "outside.yaml" + outside.write_text("model_list:\n - model_name: leaked\n litellm_params:\n model: openai/gpt-4o-mini\n") + root_config = cfg_dir / "config.yaml" + root_config.write_text("include:\n - ../outside.yaml\n\nmodel_list: []\n") + + proxy_config_instance = ProxyConfig() + with pytest.raises(ValueError, match="outside the config directory"): + await proxy_config_instance.get_config(config_file_path=str(root_config)) + + def test_add_callbacks_from_db_config(): """Test that callbacks are added correctly and duplicates are prevented""" # Setup