diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 1ee2080fdcc..25830b0088d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -361,6 +361,13 @@ class ContentFilterGuardrail(CustomGuardrail): 3. Progressively strip leading path components and try each suffix relative to this module's directory (jailed). + The directory jail can be disabled for deployments that legitimately + store category files outside the package (e.g. mounted volumes) by + setting the environment variable + ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS=true``. Use only in + trusted environments where the proxy configuration cannot be influenced + by untrusted input. + Args: file_path: The file path to resolve (absolute or relative). @@ -369,18 +376,31 @@ class ContentFilterGuardrail(CustomGuardrail): resolution fails (caller should check existence). Raises: - ValueError: If the resolved path escapes the module directory. + ValueError: If the resolved path escapes the module directory + and ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS`` is not set. """ module_dir = os.path.dirname(__file__) + allow_external = ( + os.environ.get("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", "").lower() + == "true" + ) if os.path.isabs(file_path) or os.path.exists(file_path): - self._assert_within_categories_dir(file_path, module_dir) + if not allow_external: + self._assert_within_categories_dir(file_path, module_dir) + else: + verbose_proxy_logger.warning( + "LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS is set — " + "skipping directory jail for category_file '%s'", + file_path, + ) return file_path # Try the full relative path joined to the module directory candidate = os.path.join(module_dir, file_path) if os.path.exists(candidate): - self._assert_within_categories_dir(candidate, module_dir) + if not allow_external: + self._assert_within_categories_dir(candidate, module_dir) return candidate # Progressively strip leading components to find a matching suffix @@ -389,15 +409,17 @@ class ContentFilterGuardrail(CustomGuardrail): suffix = os.path.join(*parts[i:]) candidate = os.path.join(module_dir, suffix) if os.path.exists(candidate): - self._assert_within_categories_dir(candidate, module_dir) + if not allow_external: + self._assert_within_categories_dir(candidate, module_dir) return candidate # File not found via any resolution strategy — jail the module-relative # path anyway to reject traversal attempts (e.g. "../../../../etc/passwd") # regardless of CWD or whether the target file exists. - self._assert_within_categories_dir( - os.path.join(module_dir, file_path), module_dir - ) + if not allow_external: + self._assert_within_categories_dir( + os.path.join(module_dir, file_path), module_dir + ) return file_path def _load_categories(self, categories: List[ContentFilterCategoryConfig]) -> None: diff --git a/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py b/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py index c26eac039f4..2d19fe7fe73 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py +++ b/tests/test_litellm/proxy/guardrails/test_content_filter_path_traversal.py @@ -182,3 +182,32 @@ class TestContentFilterPathTraversal: ] ) assert "valid_name" not in guardrail.loaded_categories + + def test_allow_external_paths_env_var_bypasses_jail(self, tmp_path): + """LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS=true skips the directory jail.""" + import os as _os + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, + ) + + guardrail = ContentFilterGuardrail.__new__(ContentFilterGuardrail) + # Create a real file outside the module directory (simulates mounted volume). + external_file = tmp_path / "external_categories.yaml" + external_file.write_text("category_name: test\n") + + with patch.dict( + _os.environ, {"LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS": "true"} + ): + # Should return the path without raising ValueError. + result = guardrail._resolve_category_file_path(str(external_file)) + assert result == str(external_file) + + def test_traversal_blocked_when_allow_external_not_set(self): + """Without the env var the jail still blocks traversal paths.""" + import os as _os + + guardrail = self._get_guardrail() + with patch.dict(_os.environ, {}, clear=False): + _os.environ.pop("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", None) + with pytest.raises(ValueError, match="outside the allowed categories"): + guardrail._resolve_category_file_path("/etc/passwd")