fix(guardrails): add LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS escape hatch

Operators who store category files outside the package directory (e.g.
mounted volumes) can set LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS=true
to bypass the directory jail. Default behaviour (jail on) is unchanged.
Adds 2 tests: env var bypasses jail, jail still blocks without the var.
This commit is contained in:
Avani-prajapati 2026-05-20 10:05:52 +05:30
parent 617687e496
commit 1e064e3eee
No known key found for this signature in database
2 changed files with 58 additions and 7 deletions

View file

@ -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:

View file

@ -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")