Merge pull request #41868 from BerriAI/litellm_config_update_rejects_config_owned_keys

fix(proxy): refuse config-owned keys on POST /config/update
This commit is contained in:
Mateo Wang 2026-09-18 17:23:23 -07:00 • committed by GitHub
commit 9b342cdd40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 234 additions and 70 deletions

View file

@ -1785,6 +1785,12 @@ jobs:
- wait_for_service:
url: http://localhost:4000
timeout: "300"
- run:
name: Seed the routing strategy through /config/update
command: |
curl --noproxy '*' -sSf -X POST http://localhost:4000/config/update \
-H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' \
-d '{"router_settings": {"routing_strategy": "usage-based-routing-v2"}}'
- run:
name: Run tests
command: |

View file

@ -125,6 +125,9 @@ start_proxy() {
start_proxy 4000 proxy.log
proxy_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
if [ "$suite" = management ]; then
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
start_proxy 4001 peer.log

View file

@ -6919,9 +6919,7 @@ class ProxyConfig:
self._add_callbacks_from_db_config(config_data)
# router settings
await self._add_router_settings_from_db_config(
config_data=config_data, llm_router=llm_router, prisma_client=prisma_client
)
await self._add_router_settings_from_db_config(llm_router=llm_router, prisma_client=prisma_client)
return still_desired_ids
@ -7129,13 +7127,11 @@ class ProxyConfig:
async def _add_router_settings_from_db_config(
self,
config_data: Mapping[str, object],
llm_router: Router | None,
prisma_client: PrismaClient | None,
) -> None:
if llm_router is None or prisma_client is None:
return
self.router_settings.load_yaml(_as_settings_mapping(config_data.get("router_settings")))
db_router_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "router_settings"}
)
@ -17030,6 +17026,48 @@ async def update_config(
if prisma_client is None:
raise Exception("No DB Connected")
requested_general_settings: Final[Mapping[str, JsonValue]] = (
config_info.general_settings.model_dump(exclude_none=True, exclude_unset=True)
if config_info.general_settings is not None
else {}
)
raw_litellm_settings: Final[Mapping[str, JsonValue]] = _CONFIG_SECTION_VALUES.validate_python(
config_info.litellm_settings if config_info.litellm_settings is not None else {}
)
incoming_success_callback: Final = raw_litellm_settings.get("success_callback")
updated_litellm_settings: Final[Mapping[str, JsonValue]] = _CONFIG_SECTION_VALUES.validate_python(
{
**raw_litellm_settings,
**(
{"success_callback": normalize_callback_names(incoming_success_callback)}
if isinstance(incoming_success_callback, list)
else {}
),
}
)
typed_router_settings: Final[Mapping[str, JsonValue]] = (
config_info.router_settings.model_dump(exclude_none=True, exclude_unset=True)
if config_info.router_settings is not None
else {}
)
router_settings_updates: Final[Mapping[str, JsonValue]] = {
**typed_router_settings,
**(
{
key: value
for key, value in raw_router_settings.items()
if key not in typed_router_settings and value is not None
}
if isinstance(raw_router_settings, dict)
else {}
),
}
proxy_config.reject_config_owned_writes(
section_name="general_settings", changed_keys=requested_general_settings
)
proxy_config.reject_config_owned_writes(section_name="litellm_settings", changed_keys=raw_litellm_settings)
proxy_config.reject_config_owned_writes(section_name="router_settings", changed_keys=router_settings_updates)
async def _read_section(param_name: str) -> dict:
row: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": param_name}
@ -17056,8 +17094,7 @@ async def update_config(
if config_info.general_settings is not None:
existing = await _read_section("general_settings")
before_general_settings: Final = copy.deepcopy(existing)
updates: Mapping[str, JsonValue] = config_info.general_settings.dict(exclude_none=True)
for k, v in updates.items():
for k, v in requested_general_settings.items():
if k == "alert_to_webhook_url":
if "alerting" not in existing:
existing["alerting"] = ["slack"]
@ -17100,15 +17137,9 @@ async def update_config(
if config_info.litellm_settings is not None:
existing = await _read_section("litellm_settings")
before_litellm_settings: Final = copy.deepcopy(existing)
updated_litellm_settings: Final = dict(config_info.litellm_settings)
incoming_cb = updated_litellm_settings.get("success_callback")
if isinstance(incoming_cb, list):
updated_litellm_settings["success_callback"] = normalize_callback_names(incoming_cb)
merged: Final = {**existing, **updated_litellm_settings}
incoming_cb = updated_litellm_settings.get("success_callback")
incoming_cb: Final = updated_litellm_settings.get("success_callback")
existing_cb: Final = existing.get("success_callback")
if isinstance(incoming_cb, list):
if isinstance(existing_cb, list):
@ -17131,15 +17162,6 @@ async def update_config(
if isinstance(raw_router_settings, dict):
existing = await _read_section("router_settings")
before_router_settings: Final = copy.deepcopy(existing)
typed_router_settings: Final = (
config_info.router_settings.dict(exclude_none=True) if config_info.router_settings is not None else {}
)
raw_router_settings_without_none: Final = {
key: value
for key, value in raw_router_settings.items()
if key not in typed_router_settings and value is not None
}
router_settings_updates: Final = {**typed_router_settings, **raw_router_settings_without_none}
new_router_settings: Final = {**existing, **router_settings_updates}
await _upsert_section("router_settings", new_router_settings)
asyncio.create_task(

View file

@ -213,7 +213,6 @@ files_settings:
api_key: os.environ/OPENAI_API_KEY
router_settings:
routing_strategy: usage-based-routing-v2
redis_host: os.environ/REDIS_HOST
redis_password: os.environ/REDIS_PASSWORD
redis_port: os.environ/REDIS_PORT

View file

@ -13,5 +13,4 @@ litellm_settings:
host: os.environ/REDIS_HOST
port: os.environ/REDIS_PORT
router_settings:
num_retries: 0
disable_cooldowns: true

View file

@ -3079,6 +3079,9 @@ async def test_update_config_success_callback_normalization():
async def add_deployment(self, prisma_client=None, proxy_logging_obj=None): # noqa: F811 # pytest fixture, not a redefinition
return None
def reject_config_owned_writes(self, *, section_name, changed_keys):
return None
setattr(proxy_server, "proxy_config", MockProxyConfig())
config_update = ConfigYAML(litellm_settings={"success_callback": ["SQS", "sQs"]})

View file

@ -3399,9 +3399,8 @@ async def test_ProxyConfig__add_router_settings_from_db_config_updates_router():
fake_prisma.db.litellm_config.find_first = AsyncMock(
return_value=SimpleNamespace(param_value={"timeout": 30, "retries": 2, "fallbacks": []})
)
config_data = {"router_settings": {"timeout": 10}}
pc.router_settings.load_yaml({"timeout": 10})
await pc._add_router_settings_from_db_config(
config_data=config_data,
llm_router=fake_router,
prisma_client=fake_prisma,
)
@ -3421,7 +3420,7 @@ async def test_ProxyConfig__add_router_settings_from_db_config_updates_router():
async def test_ProxyConfig__add_router_settings_from_db_config_none_router_noop():
pc = ProxyConfig()
# No router and no prisma — should silently return.
await pc._add_router_settings_from_db_config(config_data={}, llm_router=None, prisma_client=None)
await pc._add_router_settings_from_db_config(llm_router=None, prisma_client=None)
# Error-style: bad call signature raises.
with pytest.raises(TypeError):
await pc._add_router_settings_from_db_config() # type: ignore[call-arg]

View file

@ -139,6 +139,108 @@ def test_config_update_persists_disable_cooldowns(client, auth_as, mock_prisma,
assert persisted["disable_cooldowns"] is True
@pytest.mark.parametrize(
("section", "store_attr", "yaml_values", "changed_values"),
[
("general_settings", "settings", {"alerting": ["slack"]}, {"alerting": ["email"]}),
("litellm_settings", "litellm_settings", {"success_callback": ["langfuse"]}, {"success_callback": ["otel"]}),
("router_settings", "router_settings", {"num_retries": 0}, {"num_retries": 2}),
],
)
def test_config_update_rejects_config_owned_keys_and_accepts_the_same_value(
client, auth_as, mock_prisma, monkeypatch, section, store_attr, yaml_values, changed_values
):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps.proxy_config, "add_deployment", AsyncMock())
store = getattr(ps.proxy_config, store_attr)
store.load_yaml(yaml_values)
try:
with auth_as(LitellmUserRoles.PROXY_ADMIN):
rejected = client.post("/config/update", json={section: changed_values})
rejected_message = rejected.json()["error"]["message"]
table.upsert.assert_not_called()
accepted = client.post("/config/update", json={section: yaml_values})
finally:
store.load_yaml({})
assert rejected.status_code == 400
assert f"{section} key '{next(iter(yaml_values))}' is set in the config file and cannot be changed here" in (
rejected_message
)
assert accepted.status_code == 200
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
assert persisted[next(iter(yaml_values))] == yaml_values[next(iter(yaml_values))]
def test_config_update_persists_only_the_general_settings_keys_the_request_set(
client, auth_as, mock_prisma, monkeypatch
):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps.proxy_config, "add_deployment", AsyncMock())
ps.proxy_config.settings.load_yaml({"health_check_interval": 60})
try:
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.post("/config/update", json={"general_settings": {"alerting_threshold": 600}})
finally:
ps.proxy_config.settings.load_yaml({})
assert response.status_code == 200
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
assert persisted == {"alerting_threshold": 600}
def test_config_update_persists_only_the_router_settings_keys_the_request_set(
client, auth_as, mock_prisma, monkeypatch
):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps.proxy_config, "add_deployment", AsyncMock())
ps.proxy_config.router_settings.load_yaml({"model_group_alias": {"opus": "claude-opus-5"}})
try:
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.post(
"/config/update", json={"router_settings": {"retry_policy": {"TimeoutErrorRetries": 3}}}
)
finally:
ps.proxy_config.router_settings.load_yaml({})
assert response.status_code == 200, response.text
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
assert persisted == {"retry_policy": {"TimeoutErrorRetries": 3}}
def test_config_update_accepts_a_config_owned_success_callback_the_file_spells_in_mixed_case(
client, auth_as, mock_prisma, monkeypatch
):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps.proxy_config, "add_deployment", AsyncMock())
ps.proxy_config.litellm_settings.load_yaml({"success_callback": ["Langfuse"]})
try:
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.post("/config/update", json={"litellm_settings": {"success_callback": ["Langfuse"]}})
finally:
ps.proxy_config.litellm_settings.load_yaml({})
assert response.status_code == 200
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
assert persisted["success_callback"] == ["langfuse"]
def test_config_update_rejects_assistants_config(client, auth_as, mock_prisma, monkeypatch):
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles

View file

@ -4974,8 +4974,8 @@ async def test_add_router_settings_from_db_config_merge_logic():
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
# Call the method under test
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5029,9 +5029,7 @@ async def test_invalid_db_routing_groups_do_not_abort_other_router_settings():
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
await ProxyConfig()._add_router_settings_from_db_config(
config_data={}, llm_router=router, prisma_client=mock_prisma_client
)
await ProxyConfig()._add_router_settings_from_db_config(llm_router=router, prisma_client=mock_prisma_client)
assert router.num_retries == 7
assert router._model_to_group == {"m1": "g1"}
@ -5053,9 +5051,7 @@ async def test_valid_db_routing_groups_still_replace_router_groups():
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
await ProxyConfig()._add_router_settings_from_db_config(
config_data={}, llm_router=router, prisma_client=mock_prisma_client
)
await ProxyConfig()._add_router_settings_from_db_config(llm_router=router, prisma_client=mock_prisma_client)
assert router.num_retries == 7
assert router._model_to_group == {"m2": "g2"}
@ -5098,8 +5094,8 @@ async def test_add_router_settings_from_db_config_empty_db_lists_do_not_clobber_
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5135,8 +5131,8 @@ async def test_add_router_settings_from_db_config_empty_db_list_still_clears_unc
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5160,8 +5156,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
mock_router.update_settings = MagicMock()
# Test Case 1: No router provided
proxy_config.router_settings.load_yaml({"test": "value"})
await proxy_config._add_router_settings_from_db_config(
config_data={"router_settings": {"test": "value"}},
llm_router=None,
prisma_client=MagicMock(),
)
@ -5169,8 +5165,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
mock_router.update_settings.assert_not_called()
# Test Case 2: No prisma client provided
proxy_config.router_settings.load_yaml({"test": "value"})
await proxy_config._add_router_settings_from_db_config(
config_data={"router_settings": {"test": "value"}},
llm_router=mock_router,
prisma_client=None,
)
@ -5183,8 +5179,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
config_data = {"router_settings": {"routing_strategy": "usage-based"}}
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5198,8 +5194,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
mock_db_config.param_value = {"db_setting": "db_value"}
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
proxy_config.router_settings.load_yaml({})
await proxy_config._add_router_settings_from_db_config(
config_data={}, # No router_settings in config
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5211,9 +5207,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
# Test Case 5: Both config and DB router_settings are None/empty
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
await proxy_config._add_router_settings_from_db_config(
config_data={}, llm_router=mock_router, prisma_client=mock_prisma_client
)
proxy_config.router_settings.load_yaml({})
await proxy_config._add_router_settings_from_db_config(llm_router=mock_router, prisma_client=mock_prisma_client)
# Should not call update_settings when no settings exist
mock_router.update_settings.assert_not_called()
@ -5225,8 +5220,8 @@ async def test_add_router_settings_from_db_config_edge_cases():
config_data = {"router_settings": {"config_setting": "config_value"}}
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5275,8 +5270,8 @@ async def test_add_router_settings_shallow_merge_behavior():
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config)
proxy_config.router_settings.load_yaml(config_data["router_settings"])
await proxy_config._add_router_settings_from_db_config(
config_data=config_data,
llm_router=mock_router,
prisma_client=mock_prisma_client,
)
@ -5298,6 +5293,36 @@ async def test_add_router_settings_shallow_merge_behavior():
assert merged_settings["top_level"] == "config_top"
@pytest.mark.asyncio
async def test_router_settings_reload_keeps_db_values_writable(tmp_path, monkeypatch):
from litellm.proxy.proxy_server import ProxyConfig
config_path: Final = tmp_path / "config.yaml"
config_path.write_text(yaml.safe_dump({"model_list": [], "router_settings": {"disable_cooldowns": True}}))
db_row: Final = types.SimpleNamespace(param_value={"num_retries": 0})
async def read_config_row(_prisma_client, param_name):
return db_row if param_name == "router_settings" else None
mock_prisma_client: Final = MagicMock()
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=db_row)
mock_router: Final = MagicMock()
monkeypatch.setattr(proxy_server_module, "get_config_param", read_config_row)
monkeypatch.setattr(proxy_server_module, "prisma_client", mock_prisma_client)
monkeypatch.setattr(proxy_server_module, "store_model_in_db", True)
monkeypatch.setattr(proxy_server_module, "user_config_file_path", None)
proxy_config: Final = ProxyConfig()
for _ in range(2):
await proxy_config.get_config(config_file_path=str(config_path))
await proxy_config._add_router_settings_from_db_config(llm_router=mock_router, prisma_client=mock_prisma_client)
assert mock_router.update_settings.call_args.kwargs == {"disable_cooldowns": True, "num_retries": 0}
assert proxy_config.router_settings.source("num_retries") == "db"
assert proxy_config.router_settings.rejected_writes({"num_retries": 3}) == ()
assert proxy_config.router_settings.rejected_writes({"disable_cooldowns": False}) == ("disable_cooldowns",)
@pytest.mark.asyncio
async def test_model_info_v1_oci_secrets_not_leaked():
"""
@ -7585,7 +7610,15 @@ async def test_update_general_settings_db_pass_through_endpoint_cannot_override_
assert still_open.api_key is None
@pytest.fixture
def app_routes_restored():
routes_before: Final = tuple(app.router.routes)
yield
app.router.routes[:] = routes_before
@pytest.mark.asyncio
@pytest.mark.usefixtures("app_routes_restored")
async def test_deleting_the_stored_pass_through_row_takes_the_route_out_of_service():
"""A pass-through route the database declared has to stop serving when that row is
deleted. The proxy's own registry of live pass-through routes is what decides whether
@ -7624,6 +7657,7 @@ async def test_deleting_the_stored_pass_through_row_takes_the_route_out_of_servi
@pytest.mark.asyncio
@pytest.mark.usefixtures("app_routes_restored")
async def test_a_stored_pass_through_row_never_disturbs_the_config_declared_routes():
"""``pass_through_endpoints`` is config-owned once the file declares it, so writing and then
deleting a stored row resolves to the same list both times and the config file's routes keep
@ -14028,32 +14062,29 @@ async def test_authoritative_floor_spend_keeps_a_reset_marker_written_during_the
@pytest.mark.asyncio
async def test_login_throttle_settings_are_not_hot_applied_from_the_database():
"""LIT-5285: a stored sign-in limit does not take effect on a live worker.
_update_general_settings copies an allowlist of keys out of the DB row on every config
poll. Adding these to it would let a stored value outrank config.yaml without a restart,
so an operator locked out by a bad value could not fix it by editing YAML and restarting.
"""
async def test_login_throttle_limits_from_the_config_file_outrank_the_database(monkeypatch):
import litellm.proxy.proxy_server as ps
from litellm.proxy.proxy_server import ProxyConfig
original = dict(ps.general_settings)
try:
ps.general_settings.clear()
await ProxyConfig()._update_general_settings(
db_general_settings={
"max_failed_login_attempts_per_source": 999,
"failed_login_window_seconds": 1,
"failed_login_block_seconds": 1,
}
)
assert "max_failed_login_attempts_per_source" not in ps.general_settings
assert "failed_login_window_seconds" not in ps.general_settings
assert "failed_login_block_seconds" not in ps.general_settings
finally:
ps.general_settings.clear()
ps.general_settings.update(original)
monkeypatch.setattr(
ps,
"general_settings",
{
"max_failed_login_attempts_per_source": 10,
"failed_login_window_seconds": 60,
"failed_login_block_seconds": 300,
},
)
await ProxyConfig()._update_general_settings(
db_general_settings={
"max_failed_login_attempts_per_source": 999,
"failed_login_window_seconds": 1,
"failed_login_block_seconds": 1,
}
)
assert ps.general_settings.get("max_failed_login_attempts_per_source") == 10
assert ps.general_settings.get("failed_login_window_seconds") == 60
assert ps.general_settings.get("failed_login_block_seconds") == 300
@pytest.mark.asyncio

View file

@ -360,7 +360,7 @@ async def test_config_update_persists_and_reads_back_retry_policy(monkeypatch):
async def _apply_router_settings(*args, **kwargs):
await proxy_server.proxy_config._add_router_settings_from_db_config(
config_data={}, llm_router=router, prisma_client=prisma_client
llm_router=router, prisma_client=prisma_client
)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)