Merge pull request #28022 from BerriAI/litellm_/hardcore-albattani-e592b4

fix(proxy): make /config/update env-var encryption idempotent
This commit is contained in:
yuneng-jiang
2026-05-15 16:15:46 -07:00
committed by GitHub
2 changed files with 164 additions and 13 deletions
+41 -13
View File
@@ -3415,20 +3415,18 @@ class ProxyConfig:
# Make a copy to avoid mutating the original config
config_to_save = new_config.copy()
# SECURITY: Always encrypt environment_variables before DB write
# SECURITY: Always encrypt environment_variables before DB write.
# _encrypt_env_variables_for_db is idempotent — a caller that
# already encrypted the values (or re-submitted ciphertext read
# back from the DB) will not get a stacked second layer.
if (
"environment_variables" in config_to_save
and config_to_save["environment_variables"]
):
# decrypt the environment_variables - in case a caller function has already encrypted the environment_variables
decrypted_env_vars = self._decrypt_and_set_db_env_variables(
environment_variables=config_to_save["environment_variables"],
return_original_value=True,
)
# encrypt the environment_variables,
config_to_save["environment_variables"] = self._encrypt_env_variables(
environment_variables=decrypted_env_vars
config_to_save["environment_variables"] = (
self._encrypt_env_variables_for_db(
environment_variables=config_to_save["environment_variables"]
)
)
config_to_save.pop("model_list", None)
@@ -5076,6 +5074,29 @@ class ProxyConfig:
decrypted_variables[k] = decrypted_value
return decrypted_variables
def _encrypt_env_variables_for_db(
self, environment_variables: dict, new_encryption_key: Optional[str] = None
) -> dict:
"""
Idempotently encrypt environment variables for a DB write.
Config writers may pass either plaintext (first write) or values that
are already ciphertext e.g. the Admin UI reads config back via
/get/config/callbacks (which returns the stored, still-encrypted
value) and re-POSTs it on the next save. Decrypt first so an
already-encrypted value is not stacked with a second encryption
layer, then encrypt exactly once.
Decryption here deliberately uses _decrypt_db_variables (not
_decrypt_and_set_db_env_variables): this is a write path, and
loading values into os.environ is the read path's responsibility.
"""
decrypted_env_vars = self._decrypt_db_variables(environment_variables)
return self._encrypt_env_variables(
environment_variables=decrypted_env_vars,
new_encryption_key=new_encryption_key,
)
@staticmethod
def _parse_router_settings_value(value: Any) -> Optional[dict]:
"""
@@ -13788,11 +13809,18 @@ async def update_config( # noqa: PLR0915
existing[k] = v
await _upsert_section("general_settings", existing)
# environment_variables: encrypt request values, then merge into existing.
# environment_variables: idempotently encrypt the request values
# (plaintext on first write, OR ciphertext the UI read back via
# /get/config/callbacks and re-submitted on save), then merge into
# existing. Only the sent keys are re-written; untouched keys keep
# their stored ciphertext byte-for-byte.
if config_info.environment_variables is not None:
existing = await _read_section("environment_variables")
for k, v in config_info.environment_variables.items():
existing[k] = encrypt_value_helper(value=v)
existing.update(
proxy_config._encrypt_env_variables_for_db(
environment_variables=config_info.environment_variables
)
)
await _upsert_section("environment_variables", existing)
# litellm_settings: merge existing + request, request wins (matching
@@ -3850,6 +3850,65 @@ def test_update_config_fields_uppercases_env_vars(monkeypatch):
assert os.environ.get("DD_SITE") == "us5.datadoghq.com"
def test_encrypt_env_variables_for_db_is_idempotent(monkeypatch):
"""
Regression: /config/update and save_config must not stack a second
encryption layer when a caller re-submits a value that is already
ciphertext (the Admin UI reads config back from /get/config/callbacks —
which returns the stored, still-encrypted value — and re-POSTs it on the
next save). _encrypt_env_variables_for_db must yield a value that decrypts
to the original plaintext in exactly ONE layer, no matter how many times
its own output is fed back in. It must also not mutate os.environ (write
path — loading into the process env is the read path's job).
"""
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
)
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key")
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
proxy_config = ProxyConfig()
plaintext = "pk-langfuse-secret-value"
# First write: plaintext in -> single-encrypted out.
enc1 = proxy_config._encrypt_env_variables_for_db(
{"LANGFUSE_PUBLIC_KEY": plaintext}
)
assert enc1["LANGFUSE_PUBLIC_KEY"] != plaintext
assert (
decrypt_value_helper(
value=enc1["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
)
== plaintext
)
# UI round-trip: feed the ciphertext back in. Must NOT double-encrypt.
enc2 = proxy_config._encrypt_env_variables_for_db(enc1)
assert (
decrypt_value_helper(
value=enc2["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
)
== plaintext
)
# And again, ×3 total ciphertext re-feeds — still exactly one layer,
# never stacked, no matter how many times the UI re-saves.
enc3 = proxy_config._encrypt_env_variables_for_db(enc2)
enc4 = proxy_config._encrypt_env_variables_for_db(enc3)
for stacked in (enc3, enc4):
assert (
decrypt_value_helper(
value=stacked["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
)
== plaintext
)
# Write path must not leak the value into the process environment.
assert os.environ.get("LANGFUSE_PUBLIC_KEY") is None
def test_get_prompt_spec_for_db_prompt_with_versions():
"""
Test that _get_prompt_spec_for_db_prompt correctly converts database prompts
@@ -6229,6 +6288,70 @@ def test_update_config_writes_only_sent_section(_update_config_setup):
restore()
def test_update_config_env_var_round_trip_not_double_encrypted(
_update_config_setup, monkeypatch
):
"""Endpoint-level regression for the /config/update double-encryption bug.
The Admin UI reads config back via /get/config/callbacks (which returns
the stored, still-encrypted value) and re-POSTs it on the next save. The
handler must NOT stack a second encryption layer on the re-submitted
ciphertext, and must leave untouched keys byte-identical.
Uses an invertible fake encrypt/decrypt pair ("enc:" prefix) so the
decrypt-then-encrypt chokepoint round-trips faithfully. On the pre-fix
code this stored "enc:enc:..."; the assertions below would fail there.
"""
def _fake_decrypt(
value, key=None, exception_type="error", return_original_value=False
):
if isinstance(value, str) and value.startswith("enc:"):
return value[len("enc:") :]
return value if return_original_value else None
monkeypatch.setattr(
"litellm.proxy.proxy_server.decrypt_value_helper", _fake_decrypt
)
client, prisma, restore = _update_config_setup(
initial_rows={"environment_variables": {"PREEXISTING_KEY": "enc:keepme"}}
)
try:
# First write: plaintext in -> single-encrypted at rest.
resp = client.post(
"/config/update",
json={"environment_variables": {"LANGFUSE_SECRET_KEY": "sk-secret"}},
)
assert resp.status_code == 200
stored = prisma.db.litellm_config.rows["environment_variables"]
assert stored["LANGFUSE_SECRET_KEY"] == "enc:sk-secret"
# UI round-trip: re-POST the stored ciphertext (no field change).
resp = client.post(
"/config/update",
json={
"environment_variables": {
"LANGFUSE_SECRET_KEY": stored["LANGFUSE_SECRET_KEY"]
}
},
)
assert resp.status_code == 200
stored = prisma.db.litellm_config.rows["environment_variables"]
# The bug: this would be "enc:enc:sk-secret". The fix keeps it single.
assert stored["LANGFUSE_SECRET_KEY"] == "enc:sk-secret"
assert (
_fake_decrypt(stored["LANGFUSE_SECRET_KEY"], return_original_value=True)
== "sk-secret"
)
# Untouched key preserved byte-for-byte (only sent keys rewritten).
assert stored["PREEXISTING_KEY"] == "enc:keepme"
finally:
restore()
def test_update_config_can_flip_store_model_in_db_when_currently_false(
_update_config_setup,
):