Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions litellm/proxy/config_resolvers/settings_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ def _build_dual_source_keys() -> Mapping[tuple[Section, str], KeyRule]:
DUAL_SOURCE_KEYS: Final[Mapping[tuple[Section, str], KeyRule]] = _build_dual_source_keys()


RESOURCE_LIST_KEYS: Final[frozenset[tuple[Section, str]]] = frozenset({("general_settings", "pass_through_endpoints")})


def is_resource_list(section: Section, key: str) -> bool:
return (section, key) in RESOURCE_LIST_KEYS


def rule_for(section: Section, key: str) -> KeyRule:
return DUAL_SOURCE_KEYS.get((section, key), DUAL_SOURCE_KEYS[(section, "*")])

Expand Down
17 changes: 11 additions & 6 deletions litellm/proxy/config_resolvers/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Resolved,
Section,
SettingValue,
is_resource_list,
resolve,
rule_for,
)
Expand Down Expand Up @@ -49,8 +50,13 @@ def __init__(self, section: Section) -> None:
self._deleted_runtime_keys: frozenset[str] = frozenset()

def load_yaml(self, mapping: Mapping[str, JsonValue]) -> None:
self._yaml_values = MappingProxyType(dict(mapping))
self._clear_runtime()
self._yaml_values = MappingProxyType(
{key: value for key, value in mapping.items() if not is_resource_list(self._section, key)}
)
self._runtime_values = MappingProxyType(
{key: value for key, value in self._runtime_values.items() if is_resource_list(self._section, key)}
)
self._deleted_runtime_keys = frozenset()

def config_value(self, key: str) -> JsonValue:
return self._yaml_values.get(key)
Expand Down Expand Up @@ -136,10 +142,6 @@ def __len__(self) -> int:
def __bool__(self) -> bool:
return any(True for _ in self)

def _clear_runtime(self) -> None:
self._runtime_values = _EMPTY_VALUES
self._deleted_runtime_keys = frozenset()

def _clear_runtime_keys(self, keys: frozenset[str]) -> None:
stale: Final = frozenset(key for key in keys if not self.owned_by_config(key))
if not stale:
Expand All @@ -160,6 +162,9 @@ def _keys(self) -> tuple[str, ...]:
)
)

def db_value(self, key: str) -> SettingValue:
return self._db_value(key) if is_resource_list(self._section, key) else ABSENT

def _db_value(self, key: str) -> SettingValue:
rule: Final = rule_for(self._section, key)
return self._database_rows.get(rule.db_row, _EMPTY_VALUES).get(key, ABSENT)
Expand Down
114 changes: 85 additions & 29 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,13 @@ def generate_feedback_box():
)
from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys
from litellm.proxy.config_resolvers.settings_rules import (
ABSENT,
DbRow,
Section,
SettingValue,
coerce_bool,
is_absent,
is_resource_list,
)
from litellm.proxy.config_resolvers.settings_rules import (
JsonValue as SettingsJsonValue,
Expand Down Expand Up @@ -5047,6 +5051,8 @@ def update_baseline(self, config: Mapping[str, object]) -> None:

_EMPTY_SETTINGS_MAPPING: Final[Mapping[str, SettingsJsonValue]] = MappingProxyType({})
_SETTINGS_MAPPING: Final = TypeAdapter(dict[str, SettingsJsonValue])
_SETTINGS_LIST: Final = TypeAdapter(list[SettingsJsonValue])
_ENDPOINT_DICTS: Final = TypeAdapter(list[dict[str, object]])


def _as_settings_mapping(value: object) -> Mapping[str, SettingsJsonValue]:
Expand All @@ -5061,6 +5067,40 @@ def _get_field_default(field_info: FieldInfo) -> JsonValue:
return cast(JsonValue, field_info.default) # cast-ok: Pydantic field defaults are JSON values at runtime


def _pass_through_endpoints_beside_db(db_endpoints: object, config_endpoints: object) -> list[SettingsJsonValue]:
stored: Final = db_endpoints if isinstance(db_endpoints, list) else ()
declared: Final = config_endpoints if isinstance(config_endpoints, list) else ()
db_paths: Final = frozenset(endpoint.get("path") for endpoint in stored if isinstance(endpoint, dict))
beside_db: Final = (
endpoint for endpoint in declared if not isinstance(endpoint, dict) or endpoint.get("path") not in db_paths
Comment on lines +5073 to +5075

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Same-path methods lose authentication If a DB endpoint serves POST with auth: false and a YAML endpoint serves GET with auth: true on the same path, both methods can have routes, but this merge removes the YAML entry by path alone. Authentication then sees only the DB entry and can let a GET request through without a key.

How this was verified: Route registration distinguishes disjoint methods, but the merged authentication settings discard the YAML entry based only on its path.

Knowledge Base Used: Proxy authentication and authorization

)
return _SETTINGS_LIST.validate_python((*stored, *beside_db))


def _with_config_file_pass_through_endpoints(
section_config: object, resolved: Mapping[str, SettingsJsonValue], db_endpoints: SettingValue
) -> Mapping[str, object]:
config_endpoints: Final = (
section_config.get("pass_through_endpoints") if isinstance(section_config, Mapping) else None
)
if config_endpoints is None and not isinstance(db_endpoints, list) and "pass_through_endpoints" not in resolved:
return resolved
return MappingProxyType(
{
**resolved,
"pass_through_endpoints": _pass_through_endpoints_beside_db(db_endpoints, config_endpoints),
}
)


def _reload_settings_store(section: Section, store: SettingsStore, section_config: object) -> None:
serving_pass_throughs: Final = store.get("pass_through_endpoints")
store.load_yaml(_as_settings_mapping(section_config))
store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING)
if is_resource_list(section, "pass_through_endpoints") and serving_pass_throughs is not None:
store["pass_through_endpoints"] = serving_pass_throughs


def _bind_general_settings_store(settings: SettingsStore) -> None:
global general_settings
general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings
Expand Down Expand Up @@ -5151,22 +5191,18 @@ def __init__(self) -> None:
)

def _load_yaml_settings_stores(self, config: Mapping[str, object]) -> None:
global config_passthrough_endpoints
for section, store in self._settings_stores.items():
store.load_yaml(_as_settings_mapping(config.get(section)))
store.apply_db_row(section, _EMPTY_SETTINGS_MAPPING)
yaml_endpoints: Final = self.settings.config_value("pass_through_endpoints")
config_passthrough_endpoints = (
[dict(endpoint) for endpoint in yaml_endpoints if isinstance(endpoint, dict)]
if isinstance(yaml_endpoints, list)
else None
)
_reload_settings_store(section, store, config.get(section))
Comment on lines 5193 to +5195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Reload keeps old authentication When an operator changes a config pass-through from auth: false to auth: true, a YAML reload does not refresh config_passthrough_endpoints. The following DB sync re-registers the old definition, so requests can continue reaching the route without the newly required authentication.

How this was verified: YAML reload does not update the global endpoint list, and route initialization uses that list to decide whether to attach authentication.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Reloads retain stale pass-through authentication

config_passthrough_endpoints is now updated only by load_config(), not by the get_config() calls used during database reconciliation. If an operator changes a YAML route from auth: false to auth: true while the stored pass-through field contains a list (including []), _serve_pass_through_endpoints() restores the startup entry in both the live settings and route registry, allowing unauthenticated callers to continue invoking the route.

Refresh the YAML-only endpoint snapshot on reload before publishing and registering the merged list, retaining the previous serving state separately during database reads.


def _config_with_resolved_settings(self, config: Mapping[str, object]) -> dict[str, object]:
return { # mutable-ok: get_config preserves the mutable mapping contract used by existing loaders
**config,
**{
section: dict(store.resolved())
section: dict(
_with_config_file_pass_through_endpoints(
config.get(section), store.resolved(), store.db_value("pass_through_endpoints")
)
)
for section, store in self._settings_stores.items()
if isinstance(config.get(section), Mapping) or len(store) > 0
},
Expand Down Expand Up @@ -6525,6 +6561,7 @@ async def load_config(self, router: litellm.Router | None, config_file_path: str

## pass through endpoints
if general_settings.get("pass_through_endpoints", None) is not None:
config_passthrough_endpoints = general_settings["pass_through_endpoints"]
await initialize_pass_through_endpoints(
pass_through_endpoints=general_settings["pass_through_endpoints"],
config_file_path=config_file_path,
Expand Down Expand Up @@ -7548,14 +7585,12 @@ async def _update_general_settings(self, db_general_settings: Mapping[str, Setti
self.settings.load_yaml(_as_settings_mapping(general_settings))
cache_size_was_db: Final = self.settings.source("user_api_key_cache_max_size") == "db"
previous_retention_values: Final = self._resolved_retention_values()
previous_pass_through_endpoints: Final = self.settings.get("pass_through_endpoints")
self.settings.apply_db_row("general_settings", db_general_settings)
_bind_general_settings_store(self.settings)
await self._apply_general_settings_side_effects(
db_general_settings,
cache_size_was_db,
previous_retention_values,
previous_pass_through_endpoints,
)

def _resolved_retention_values(self) -> tuple[SettingsJsonValue | None, ...]:
Expand All @@ -7573,11 +7608,10 @@ async def _apply_general_settings_side_effects(
db_values: Mapping[str, SettingsJsonValue],
cache_size_was_db: bool,
previous_retention_values: tuple[SettingsJsonValue | None, ...],
previous_pass_through_endpoints: SettingsJsonValue | None,
) -> None:
effects: Final = (
self._apply_alerting_settings,
partial(self._apply_pass_through_settings, previous_endpoints=previous_pass_through_endpoints),
self._apply_pass_through_settings,
self._apply_boolean_settings,
partial(self._apply_cache_size_setting, cache_size_was_db=cache_size_was_db),
self._apply_store_model_in_db_setting,
Expand Down Expand Up @@ -7610,19 +7644,23 @@ async def _apply_alerting_settings(self, db_values: Mapping[str, SettingsJsonVal
if "plugins" in db_values and self.settings.source("plugins") == "db":
register_plugins_from_config(self.settings)

async def _apply_pass_through_settings(
self,
db_values: Mapping[str, SettingsJsonValue],
previous_endpoints: SettingsJsonValue | None,
) -> None:
del db_values
resolved_endpoints: Final = self.settings.get("pass_through_endpoints")
if resolved_endpoints == previous_endpoints:
async def _apply_pass_through_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
db_endpoints: Final = db_values.get("pass_through_endpoints")
if isinstance(db_endpoints, list):
await self._serve_pass_through_endpoints(db_endpoints)
return
await initialize_pass_through_endpoints(
pass_through_endpoints=resolved_endpoints if isinstance(resolved_endpoints, list) else []
if "pass_through_endpoints" not in self.settings:
self._publish_pass_through_endpoints(())
Comment on lines +7652 to +7653

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Deleted routes remain registered If supported_db_objects excludes pass-through endpoints, a DB sync that removes the pass-through field reaches this branch but skips the later route initialization. This branch changes settings without removing registered routes, so a deleted endpoint that previously had auth: false can remain reachable.

How this was verified: The absent-field branch only updates settings; stale route entries are removed during initialization, which the supported-object setting can skip.

Knowledge Base Used:


def _publish_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None:
self.settings["pass_through_endpoints"] = _pass_through_endpoints_beside_db(
list(db_endpoints), config_passthrough_endpoints
)

async def _serve_pass_through_endpoints(self, db_endpoints: Sequence[SettingsJsonValue]) -> None:
self._publish_pass_through_endpoints(db_endpoints)
await initialize_pass_through_endpoints(pass_through_endpoints=_ENDPOINT_DICTS.validate_python(db_endpoints))

async def _apply_boolean_settings(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
for key in (
"store_prompts_in_spend_logs",
Expand Down Expand Up @@ -18030,6 +18068,9 @@ async def update_config_general_settings(
)
await invalidate_config_param("general_settings")
proxy_config.settings.apply_db_row("general_settings", general_settings)
if is_resource_list("general_settings", data.field_name):
stored_endpoints: Final = general_settings.get("pass_through_endpoints")
await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ())
asyncio.create_task(
create_config_audit_log(
"general_settings", "updated", before_general_settings, general_settings, user_api_key_dict
Expand Down Expand Up @@ -18181,6 +18222,21 @@ def _apply_webhook_role_gate(webhook_map, is_full_admin: bool):
return {alert_type: "REDACTED" for alert_type in webhook_map}


async def _declared_general_setting(
settings: SettingsStore, field_name: str, prisma_client: PrismaClient
) -> SettingValue:
if is_resource_list("general_settings", field_name):
writer_table: Final = cast( # cast-ok: prisma's LiteLLM_Config actions object on the writer connection
"TableActions[_ConfigParamRow]", prisma_client.writer_db.litellm_config
)
row: Final = await writer_table.find_first(where={"param_name": "general_settings"})
stored: Final = row.param_value if row is not None and isinstance(row.param_value, Mapping) else {}
return stored.get(field_name, ABSENT) if stored.get(field_name) is not None else ABSENT
if field_name not in settings:
return ABSENT
return settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name]


@router.get(
"/config/field/info",
tags=["config.yaml"],
Expand Down Expand Up @@ -18219,15 +18275,12 @@ async def get_config_general_settings(
)

settings: Final = proxy_config.settings
if field_name not in settings:
declared: Final = await _declared_general_setting(settings, field_name, prisma_client)
if is_absent(declared):
raise HTTPException(
status_code=400,
detail={"error": f"Field name={field_name} is not set"},
)

declared: Final = (
settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name]
)
field_value = _redact_general_setting_value(
field_name,
declared,
Expand Down Expand Up @@ -18638,6 +18691,9 @@ async def delete_config_general_settings(
)
await invalidate_config_param("general_settings")
proxy_config.settings.apply_db_row("general_settings", general_settings)
if is_resource_list("general_settings", data.field_name):
stored_endpoints: Final = general_settings.get("pass_through_endpoints")
await proxy_config._serve_pass_through_endpoints(stored_endpoints if isinstance(stored_endpoints, list) else ())
asyncio.create_task(
create_config_audit_log(
"general_settings", "deleted", before_general_settings, general_settings, user_api_key_dict
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Section,
SettingValue,
is_absent,
is_resource_list,
resolve,
rule_for,
)
Expand Down Expand Up @@ -87,7 +88,6 @@
"user_url_allowed_hosts",
"provider_url_destination_allowed_hosts",
"alerting",
"pass_through_endpoints",
)


Expand All @@ -104,8 +104,9 @@ def test_the_store_resolves_every_config_and_stored_value_combination(
section: Section, key: str, config_value: SettingValue, db_value: SettingValue
) -> None:
store: Final = _store_for(section, key, config_value, db_value)
owned_config_value: Final = ABSENT if is_resource_list(section, key) else config_value

if not is_absent(config_value):
if not is_absent(owned_config_value):
assert store[key] == config_value
assert store.source(key) == "config"
elif is_absent(db_value) or db_value is None:
Expand All @@ -120,7 +121,7 @@ def test_the_store_resolves_every_config_and_stored_value_combination(
def test_the_store_and_the_resolver_never_disagree(
section: Section, key: str, config_value: SettingValue, db_value: SettingValue
) -> None:
resolved: Final = resolve(config_value, db_value)
resolved: Final = resolve(ABSENT if is_resource_list(section, key) else config_value, db_value)
store: Final = _store_for(section, key, config_value, db_value)

assert store.source(key) == resolved.source
Expand Down
22 changes: 22 additions & 0 deletions tests/test_litellm/proxy/config_resolvers/test_settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,28 @@ async def test_load_config_returns_and_binds_the_general_settings_store(tmp_path
assert config_state["general_settings"]["max_file_size_mb"] == 5


def test_settings_store_leaves_pass_through_endpoints_to_the_database() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"pass_through_endpoints": [{"path": "/config"}]})
store.apply_db_row("general_settings", {"pass_through_endpoints": [{"path": "/db"}]})

assert store["pass_through_endpoints"] == [{"path": "/db"}]
assert store.source("pass_through_endpoints") == "db"
assert store.rejected_writes({"pass_through_endpoints": [{"path": "/ui"}]}) == ()


def test_settings_store_keeps_serving_pass_through_endpoints_while_the_config_file_reloads() -> None:
store: Final = SettingsStore("general_settings")
store.load_yaml({"pass_through_endpoints": [{"path": "/config"}], "max_parallel_requests": 1})
store["pass_through_endpoints"] = [{"path": "/config", "auth": False}]
store["allowed_ips"] = ["1.2.3.4"]

store.load_yaml({"pass_through_endpoints": [{"path": "/config"}], "max_parallel_requests": 1})

assert store["pass_through_endpoints"] == [{"path": "/config", "auth": False}]
assert "allowed_ips" not in store


def test_settings_store_starts_with_an_unset_source() -> None:
store: Final = SettingsStore("general_settings")

Expand Down
Loading
Loading