Merge pull request #25592 from BerriAI/litellm_tightenEnvReferenceHandling

[Fix] tighten handling of environment references in request parameters
This commit is contained in:
yuneng-jiang
2026-04-11 22:47:39 -07:00
committed by GitHub
8 changed files with 158 additions and 97 deletions
+21 -2
View File
@@ -57,12 +57,31 @@ oidc/config_name_here/
#### Unofficial Providers (not recommended)
For the unofficial `file` provider, you can use the following format:
For the unofficial `file` provider, you can use the following format
(note the double slash — the path after `oidc/file/` must be absolute):
```
oidc/file/home/user/dave/this_is_a_file_with_a_token.txt
oidc/file//var/run/secrets/my-token
```
For safety, the resolved path must live inside an allowed credential
directory. By default the following directories are allowed:
- `/var/run/secrets`
- `/run/secrets`
If your deployment mounts credentials elsewhere, set the
`LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS` environment variable to a
comma-separated list of absolute directories. The value replaces the
default list, so include the defaults if you still need them:
```bash
export LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS="/var/run/secrets,/etc/litellm/creds"
```
Paths that resolve (after following symlinks and `..`) outside the
allowlist are rejected.
For the unofficial `env`, use the following format, where `SECRET_TOKEN` is the name of the environment variable that contains the token:
```
@@ -830,6 +830,7 @@ router_settings:
| LITELLM_KEY_ROTATION_LOCK_TTL_SECONDS | TTL in seconds for the distributed lock used by the key rotation job. Default is 600 (10 minutes).
| LITELLM_LICENSE | License key for LiteLLM usage
| LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS | Set to `True` to use the local bundled Anthropic beta headers config only, disabling remote fetching. Default is `False`
| LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS | Comma-separated list of absolute directories from which the `oidc/file/` provider is permitted to read token files. Defaults to `/var/run/secrets,/run/secrets`.
| LITELLM_LOCAL_BLOG_POSTS | When set to `True`, uses the local bundled blog posts only, disabling remote fetching from GitHub. Default is `False`
| LITELLM_LOCAL_MODEL_COST_MAP | Local configuration for model cost mapping in LiteLLM
| LITELLM_LOCAL_POLICY_TEMPLATES | When set to "true", uses local backup policy templates instead of fetching from GitHub. Policy templates are fetched from https://raw.githubusercontent.com/BerriAI/litellm/main/policy_templates.json by default, with automatic fallback to local backup on failure
@@ -1,7 +1,12 @@
from typing import Dict, Optional
from litellm.secret_managers.main import get_secret_str
from litellm._logging import verbose_logger
from litellm.types.utils import StandardCallbackDynamicParams
def _is_env_reference(value: object) -> bool:
return isinstance(value, str) and "os.environ/" in value
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
_supported_callback_params = [
"langfuse_public_key",
@@ -46,12 +51,14 @@ def initialize_standard_callback_dynamic_params(
for param in _supported_callback_params:
if param in kwargs:
_param_value = kwargs.get(param)
if (
_param_value is not None
and isinstance(_param_value, str)
and "os.environ/" in _param_value
):
_param_value = get_secret_str(secret_name=_param_value)
if _is_env_reference(_param_value):
verbose_logger.warning(
"Dropping callback param '%s': os.environ/ references "
"in request-supplied parameters are not resolved. "
"Configure this value server-side instead.",
param,
)
continue
standard_callback_dynamic_params[param] = _param_value # type: ignore
# 2. Fallback: check "metadata" or "litellm_params" -> "metadata"
@@ -64,12 +71,15 @@ def initialize_standard_callback_dynamic_params(
for param in _supported_callback_params:
if param not in standard_callback_dynamic_params and param in metadata:
_param_value = metadata.get(param)
if (
_param_value is not None
and isinstance(_param_value, str)
and "os.environ/" in _param_value
):
_param_value = get_secret_str(secret_name=_param_value)
if _is_env_reference(_param_value):
verbose_logger.warning(
"Dropping callback param '%s' from metadata: "
"os.environ/ references in request-supplied "
"parameters are not resolved. Configure this "
"value server-side instead.",
param,
)
continue
standard_callback_dynamic_params[param] = _param_value # type: ignore
return standard_callback_dynamic_params
@@ -5,7 +5,7 @@ import os
import time
import traceback
from datetime import datetime, timedelta
from typing import Any, Dict, Literal, Optional, Union, cast
from typing import Any, Dict, Iterable, Literal, Optional, Union, cast
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
@@ -36,79 +36,43 @@ from litellm.proxy.health_check import (
from litellm.proxy.middleware.in_flight_requests_middleware import (
get_in_flight_requests,
)
from litellm.secret_managers.main import get_secret
#### Health ENDPOINTS ####
def _resolve_os_environ_variables(params: dict) -> dict:
def _reject_os_environ_references(params: dict) -> None:
"""
Resolve ``os.environ/`` environment variables in ``litellm_params``.
This walks the input dict/list structure iteratively (no Python recursion) to
avoid unbounded recursion / stack overflows on deeply nested inputs.
Validate that the provided params do not contain any ``os.environ/``
references. Values with that prefix are expected to come only from
server-side configuration (already resolved before reaching here). If a
request-supplied value still carries the prefix, raise ``HTTPException``.
"""
if not isinstance(params, dict):
return params
return
# Use an explicit stack to avoid recursion and handle nested dicts/lists.
# We also keep a `seen` set to guard against accidental cycles.
resolved_root: dict = {}
stack: list[tuple[object, object]] = [(params, resolved_root)]
stack: list[object] = [params]
seen: set[int] = {id(params)}
while stack:
src, dst = stack.pop()
src = stack.pop()
if isinstance(src, dict):
values: Iterable[object] = src.values()
elif isinstance(src, list):
values = src
else:
continue
if isinstance(src, dict) and isinstance(dst, dict):
for key, value in src.items():
# Direct string replacement for os.environ/ references
if isinstance(value, str) and value.startswith("os.environ/"):
dst[key] = get_secret(value)
elif isinstance(value, dict):
if id(value) in seen:
# Cycle detected keep a shallow copy reference to prevent infinite loops
dst[key] = {}
continue
seen.add(id(value))
new_dict: dict = {}
dst[key] = new_dict
stack.append((value, new_dict))
elif isinstance(value, list):
if id(value) in seen:
dst[key] = []
continue
seen.add(id(value))
new_list: list = []
dst[key] = new_list
stack.append((value, new_list))
else:
dst[key] = value
elif isinstance(src, list) and isinstance(dst, list):
for item in src:
if isinstance(item, str) and item.startswith("os.environ/"):
dst.append(get_secret(item))
elif isinstance(item, dict):
if id(item) in seen:
dst.append({})
continue
seen.add(id(item))
new_dict = {}
dst.append(new_dict)
stack.append((item, new_dict))
elif isinstance(item, list):
if id(item) in seen:
dst.append([])
continue
seen.add(id(item))
new_list = []
dst.append(new_list)
stack.append((item, new_list))
else:
dst.append(item)
return resolved_root
for value in values:
if isinstance(value, str) and value.startswith("os.environ/"):
raise HTTPException(
status_code=400,
detail={
"error": "Environment variable references are not permitted in request parameters."
},
)
if isinstance(value, (dict, list)) and id(value) not in seen:
seen.add(id(value))
stack.append(value)
def get_callback_identifier(callback):
@@ -1510,6 +1474,10 @@ async def test_model_connection(
# Get model name from litellm_params
request_litellm_params = litellm_params or {}
# Reject request-supplied os.environ/ references. Config values are
# already resolved before reaching this endpoint; any remaining
# reference must have come from the request body.
_reject_os_environ_references(request_litellm_params)
model_name = request_litellm_params.get("model")
# Look up model configuration from router if model name is provided
@@ -1546,11 +1514,7 @@ async def test_model_connection(
# Merge: config params (from proxy config) as base, request params override
# This allows users to override specific params while using config for credentials
merged_litellm_params = {**config_litellm_params, **request_litellm_params}
# Resolve os.environ/ environment variables in any remaining request params
# This handles cases where user explicitly passes os.environ/ values to override config
litellm_params = _resolve_os_environ_variables(merged_litellm_params)
litellm_params = {**config_litellm_params, **request_litellm_params}
## Auth check
await ModelManagementAuthChecks.can_user_make_model_call(
+49 -2
View File
@@ -16,6 +16,52 @@ from litellm.secret_managers.secret_manager_handler import get_secret_from_manag
oidc_cache = DualCache()
_DEFAULT_OIDC_ALLOWED_CREDENTIAL_DIRS = ("/var/run/secrets", "/run/secrets")
def _get_oidc_allowed_credential_dirs() -> list[str]:
"""
Return the absolute, normalized list of directories from which
``oidc/file/`` is permitted to read token files.
Defaults to standard container credential mount points. Operators can
override via the ``LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS`` environment
variable (comma-separated list of absolute paths).
"""
override = os.getenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS")
raw_dirs = (
[d.strip() for d in override.split(",") if d.strip()]
if override
else list(_DEFAULT_OIDC_ALLOWED_CREDENTIAL_DIRS)
)
return [os.path.realpath(d) for d in raw_dirs]
def _resolve_oidc_file_path(requested_path: str) -> str:
"""
Resolve ``requested_path`` and verify it falls within one of the allowed
credential directories. Raises ``ValueError`` otherwise.
"""
if not os.path.isabs(requested_path):
raise ValueError(
"oidc/file path must be absolute. Use the format "
"'oidc/file//var/run/secrets/<name>' (note the leading slash "
"after 'oidc/file/')."
)
resolved = os.path.realpath(requested_path)
for allowed in _get_oidc_allowed_credential_dirs():
try:
if os.path.commonpath([resolved, allowed]) == allowed:
return resolved
except ValueError:
# commonpath raises when paths are on different drives (Windows);
# treat as not-matching and continue.
continue
raise ValueError(
"oidc/file path is outside the allowed credential directories. "
"Set LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS to extend the allowlist."
)
def _get_oidc_http_handler(timeout: Optional[httpx.Timeout] = None) -> HTTPHandler:
"""
@@ -196,8 +242,9 @@ def get_secret( # noqa: PLR0915
oidc_token = f.read()
return oidc_token
elif oidc_provider == "file":
# Load token from a file
with open(oidc_aud, "r") as f:
# Load token from a file within an allowed credential directory.
safe_path = _resolve_oidc_file_path(oidc_aud)
with open(safe_path, "r") as f:
oidc_token = f.read()
return oidc_token
elif oidc_provider == "env":
@@ -183,13 +183,14 @@ def test_oidc_env_variable():
del os.environ[env_var_name]
def test_oidc_file():
# Create a temporary file
with tempfile.NamedTemporaryFile(mode="w+") as temp_file:
def test_oidc_file(monkeypatch):
# Create a temporary file inside a directory added to the allowlist.
with tempfile.TemporaryDirectory() as temp_dir:
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", temp_dir)
temp_file_path = os.path.join(temp_dir, "token.txt")
secret_value = "secret-" + uuid4().hex
temp_file.write(secret_value)
temp_file.flush()
temp_file_path = temp_file.name
with open(temp_file_path, "w") as temp_file:
temp_file.write(secret_value)
secret_val = get_secret(f"oidc/file/{temp_file_path}")
@@ -450,10 +450,10 @@ async def test_test_model_connection_loads_config_from_router():
params["messages"] = [{"role": "user", "content": "test"}]
return params
# Mock _resolve_os_environ_variables
def mock_resolve_os_environ(params):
return params
# Mock _reject_os_environ_references
def mock_reject_os_environ(params):
return None
with patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
@@ -476,8 +476,8 @@ async def test_test_model_connection_loads_config_from_router():
"litellm.proxy.health_endpoints._health_endpoints._update_litellm_params_for_health_check",
mock_update_params,
), patch(
"litellm.proxy.health_endpoints._health_endpoints._resolve_os_environ_variables",
mock_resolve_os_environ,
"litellm.proxy.health_endpoints._health_endpoints._reject_os_environ_references",
mock_reject_os_environ,
):
# Call the endpoint with only model name (no credentials)
result = await health_test_model_connection(
@@ -199,9 +199,10 @@ def test_oidc_azure_ad_token_success(mock_get_azure_ad_token_provider, monkeypat
mock_token_provider.assert_called_once_with()
def test_oidc_file_success(tmp_path):
def test_oidc_file_success(tmp_path, monkeypatch):
token_file = tmp_path / "token.txt"
token_file.write_text("file_token")
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path))
secret_name = f"oidc/file/{token_file}"
result = get_secret(secret_name)
@@ -209,6 +210,24 @@ def test_oidc_file_success(tmp_path):
assert result == "file_token"
def test_oidc_file_rejects_path_outside_allowlist(tmp_path, monkeypatch):
outside_file = tmp_path / "outside.txt"
outside_file.write_text("should_not_read")
# Allowlist a different directory.
allowed_dir = tmp_path / "allowed"
allowed_dir.mkdir()
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(allowed_dir))
with pytest.raises(ValueError, match="outside the allowed credential directories"):
get_secret(f"oidc/file/{outside_file}")
def test_oidc_file_rejects_relative_path(tmp_path, monkeypatch):
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path))
with pytest.raises(ValueError, match="must be absolute"):
get_secret("oidc/file/relative/path/token")
def test_oidc_env_success(mock_env):
mock_env["CUSTOM_TOKEN"] = "env_token"