From db7c9aa5469d0e1e8f09cc959be7c6efa06e6eb8 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 27 Oct 2026 10:31:54 -0700 Subject: [PATCH] feat(proxy): add LITELLM_FIPS_MODE startup gate With LITELLM_FIPS_MODE=true the proxy refuses to start when TLS verification is off, when Python's OpenSSL does not enforce FIPS, or when the startup password migration fails. A value that is not true/false also refuses. --- litellm/proxy/common_utils/fips.py | 53 ++++++++++++ litellm/proxy/proxy_server.py | 10 ++- .../configuration/test_fips_mode_boot.py | 62 ++++++++++++++ tests/unit/proxy/common_utils/test_fips.py | 85 +++++++++++++++++++ ...test_proxy_server_endpoints_and_startup.py | 39 +++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/common_utils/fips.py create mode 100644 tests/integration/configuration/test_fips_mode_boot.py create mode 100644 tests/unit/proxy/common_utils/test_fips.py diff --git a/litellm/proxy/common_utils/fips.py b/litellm/proxy/common_utils/fips.py new file mode 100644 index 00000000000..3a8df10b9e5 --- /dev/null +++ b/litellm/proxy/common_utils/fips.py @@ -0,0 +1,53 @@ +import os +from typing import Final + +from litellm.secret_managers.main import str_to_bool + +FIPS_MODE_ENV_VAR: Final = "LITELLM_FIPS_MODE" + + +class FipsModeError(Exception): + pass + + +def is_fips_mode() -> bool: + return str_to_bool(os.getenv(FIPS_MODE_ENV_VAR)) is True + + +def openssl_enforces_fips() -> bool: + """Python's OpenSSL runs with FIPS-only defaults and itself refuses MD5.""" + try: + import _hashlib + + if _hashlib.get_fips_mode() != 1: + return False + except (ImportError, AttributeError, ValueError): + return False + try: + _hashlib.new("md5", b"", usedforsecurity=True) + except ValueError: + return True + return False + + +def enforce_fips_mode() -> None: + """Refuse to start when LITELLM_FIPS_MODE is not a boolean, or is on but TLS verification is off + or OpenSSL does not enforce FIPS.""" + value: Final = os.getenv(FIPS_MODE_ENV_VAR) + if value and str_to_bool(value) is None: + raise FipsModeError(f"{FIPS_MODE_ENV_VAR}={value!r} is not a boolean. Set it to true or false, or unset it.") + if not is_fips_mode(): + return + + from litellm.llms.custom_httpx.http_handler import get_ssl_verify + + if get_ssl_verify() is False: + raise FipsModeError( + f"{FIPS_MODE_ENV_VAR} is on but TLS certificate verification is disabled by SSL_VERIFY or " + "litellm_settings.ssl_verify. Remove the override, or point ssl_verify at a CA bundle." + ) + if not openssl_enforces_fips(): + raise FipsModeError( + f"{FIPS_MODE_ENV_VAR} is on but OpenSSL does not enforce FIPS. Run the proxy on an OpenSSL with " + f"its FIPS provider active and default_properties = fips=yes, or unset {FIPS_MODE_ENV_VAR}." + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bcba76ca1b3..5aacc7ad1e6 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -489,6 +489,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( encrypt_value_helper, ) from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id +from litellm.proxy.common_utils.fips import FipsModeError, enforce_fips_mode, is_fips_mode from litellm.proxy.common_utils.healthy_model_filter import ( get_hidden_unhealthy_model_names, is_healthy_only_listing_default, @@ -1523,6 +1524,8 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState if decoded_worker_config is not None: await initialize_from_worker_config(decoded_worker_config) + enforce_fips_mode() + enforce_master_key_boot_verdict( await with_stored_secrets_counted( master_key_boot_verdict( @@ -1565,9 +1568,14 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState result: Final = await migrate_passwords_to_scrypt_async(prisma_client) verbose_proxy_logger.info("Password migration: %s", result) except Exception as e: + if is_fips_mode(): + raise FipsModeError(f"Password migration failed, so plaintext passwords stay unhashed: {e}") from e verbose_proxy_logger.warning("Password migration skipped: %s", e) - asyncio.create_task(_run_pw_migration()) + if is_fips_mode(): + await _run_pw_migration() + else: + asyncio.create_task(_run_pw_migration()) async def _run_agent_grant_id_migration() -> None: from litellm.proxy.agent_endpoints.agent_registry import ( diff --git a/tests/integration/configuration/test_fips_mode_boot.py b/tests/integration/configuration/test_fips_mode_boot.py new file mode 100644 index 00000000000..85c7011876a --- /dev/null +++ b/tests/integration/configuration/test_fips_mode_boot.py @@ -0,0 +1,62 @@ +import _hashlib +import json +from pathlib import Path +from typing import Final + +import pytest + +from tests.integration._support.client import Gateway +from tests.integration._support.process import owned_proxy, refused_boot_log + + +def _config(directory: Path, upstream_url: str, *, ssl_verify: bool = True) -> Path: + config: Final = directory / "fips_boot.yaml" + config.write_text( + json.dumps( + { + "model_list": [ + { + "model_name": "fips-model", + "litellm_params": {"model": "openai/fips-model", "api_base": upstream_url, "api_key": "sk-x"}, + } + ], + "litellm_settings": {"ssl_verify": ssl_verify}, + } + ) + ) + return config + + +def test_fips_mode_refuses_to_start_when_openssl_does_not_enforce_fips(gateway: Gateway, tmp_path: Path) -> None: + if _hashlib.get_fips_mode() == 1: + pytest.skip("this runner's OpenSSL is in FIPS mode") + log: Final = refused_boot_log( + gateway, tmp_path, {"LITELLM_FIPS_MODE": "true"}, config=_config(tmp_path, gateway.upstream_url) + ) + assert "LITELLM_FIPS_MODE is on but OpenSSL does not enforce FIPS" in log, log + + +@pytest.mark.parametrize( + "overrides, ssl_verify", + [({"SSL_VERIFY": "false"}, True), ({}, False)], + ids=["ssl-verify-env", "litellm-settings"], +) +def test_fips_mode_refuses_to_start_with_tls_verification_disabled( + gateway: Gateway, tmp_path: Path, overrides: dict[str, str], ssl_verify: bool +) -> None: + config: Final = _config(tmp_path, gateway.upstream_url, ssl_verify=ssl_verify) + log: Final = refused_boot_log(gateway, tmp_path, {"LITELLM_FIPS_MODE": "true", **overrides}, config=config) + assert "LITELLM_FIPS_MODE is on but TLS certificate verification is disabled" in log, log + + +def test_fips_mode_refuses_to_start_on_a_value_that_is_not_a_boolean(gateway: Gateway, tmp_path: Path) -> None: + log: Final = refused_boot_log( + gateway, tmp_path, {"LITELLM_FIPS_MODE": "enforced"}, config=_config(tmp_path, gateway.upstream_url) + ) + assert "LITELLM_FIPS_MODE='enforced' is not a boolean" in log, log + + +def test_fips_mode_off_starts_with_tls_verification_disabled(gateway: Gateway, tmp_path: Path) -> None: + config: Final = _config(tmp_path, gateway.upstream_url, ssl_verify=False) + with owned_proxy(gateway, tmp_path, {"SSL_VERIFY": "false"}, config=config) as proxy: + assert proxy.client.get("/health/readiness").status_code == 200 diff --git a/tests/unit/proxy/common_utils/test_fips.py b/tests/unit/proxy/common_utils/test_fips.py new file mode 100644 index 00000000000..392db0d9cb4 --- /dev/null +++ b/tests/unit/proxy/common_utils/test_fips.py @@ -0,0 +1,85 @@ +import _hashlib +from contextlib import nullcontext + +import pytest + +import litellm +from litellm.proxy.common_utils.fips import FipsModeError, enforce_fips_mode, is_fips_mode, openssl_enforces_fips + + +def _fake_openssl(monkeypatch, *, fips_flag: int, md5_refused: bool) -> None: + real_new = _hashlib.new + + def new(name, data=b"", **kwargs): + if name == "md5" and md5_refused: + raise _hashlib.UnsupportedDigestmodError("unsupported hash type") + return real_new(name, data, **kwargs) + + monkeypatch.setattr(_hashlib, "get_fips_mode", lambda: fips_flag) + monkeypatch.setattr(_hashlib, "new", new) + + +@pytest.mark.parametrize("value, expected", [("true", True), (" TRUE ", True), ("false", False), (None, False)]) +def test_is_fips_mode(monkeypatch, value, expected): + if value is None: + monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False) + else: + monkeypatch.setenv("LITELLM_FIPS_MODE", value) + assert is_fips_mode() is expected + + +@pytest.mark.parametrize( + "fips_flag, md5_refused, enforces", + [(1, True, True), (0, True, False), (1, False, False)], + ids=["fips", "fips-flag-off", "md5-allowed"], +) +def test_openssl_enforces_fips_needs_the_fips_flag_and_a_refused_md5(monkeypatch, fips_flag, md5_refused, enforces): + _fake_openssl(monkeypatch, fips_flag=fips_flag, md5_refused=md5_refused) + assert openssl_enforces_fips() is enforces + + +def test_openssl_without_get_fips_mode_does_not_enforce_fips(monkeypatch): + monkeypatch.delattr(_hashlib, "get_fips_mode") + assert openssl_enforces_fips() is False + + +def test_this_runners_openssl_does_not_enforce_fips(): + if _hashlib.get_fips_mode() == 1: + pytest.skip("this runner's OpenSSL is in FIPS mode") + assert openssl_enforces_fips() is False + + +@pytest.mark.parametrize( + "fips_mode, ssl_verify_env, ssl_verify_setting, openssl_fips, refusal", + [ + (None, "false", False, False, None), + ("", "false", False, False, None), + ("false", "false", False, False, None), + ("true", None, True, True, None), + ("true", "false", True, True, "TLS certificate verification is disabled"), + ("true", None, False, True, "TLS certificate verification is disabled"), + ("true", None, True, False, "OpenSSL does not enforce FIPS"), + ("enforced", None, True, True, "LITELLM_FIPS_MODE='enforced' is not a boolean"), + ], + ids=[ + "unset", + "empty", + "false", + "fips", + "ssl-verify-env-off", + "ssl-verify-setting-off", + "openssl-not-fips", + "not-a-boolean", + ], +) +def test_enforce_fips_mode(monkeypatch, fips_mode, ssl_verify_env, ssl_verify_setting, openssl_fips, refusal): + for name, value in (("LITELLM_FIPS_MODE", fips_mode), ("SSL_VERIFY", ssl_verify_env)): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + monkeypatch.setattr(litellm, "ssl_verify", ssl_verify_setting) + _fake_openssl(monkeypatch, fips_flag=int(openssl_fips), md5_refused=openssl_fips) + + with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext(): + enforce_fips_mode() diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 745488bf93f..12f0161ab0c 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -1775,6 +1775,45 @@ async def test_proxy_startup_refuses_an_unsafe_master_key_even_when_the_database assert ("could not be checked" in announced[0]) == key_can_have_encrypted_the_database +@pytest.mark.asyncio +async def test_proxy_startup_refuses_fips_mode_when_the_config_disables_tls_verification(monkeypatch, tmp_path): + from litellm.proxy.common_utils.fips import FipsModeError + from litellm.proxy.proxy_server import proxy_startup_event + + config_path, _ = _boot_with_general_settings(monkeypatch, tmp_path, {"master_key": "sk-a-safe-master-key"}) + config_path.write_text(yaml.dump({"litellm_settings": {"ssl_verify": False}})) + monkeypatch.setattr(litellm, "ssl_verify", True) + monkeypatch.delenv("SSL_VERIFY", raising=False) + monkeypatch.setenv("LITELLM_FIPS_MODE", "true") + + with pytest.raises(FipsModeError, match="TLS certificate verification is disabled"): + async with proxy_startup_event(FastAPI()): + pass + + +@pytest.mark.asyncio +async def test_proxy_startup_refuses_fips_mode_when_the_password_migration_fails(monkeypatch, tmp_path): + from litellm.proxy.common_utils.fips import FipsModeError + from litellm.proxy.proxy_server import ProxyStartupEvent, proxy_startup_event + + _boot_with_general_settings(monkeypatch, tmp_path, {"master_key": "sk-a-safe-master-key"}) + monkeypatch.setattr(litellm, "ssl_verify", True) + monkeypatch.delenv("SSL_VERIFY", raising=False) + monkeypatch.setenv("LITELLM_FIPS_MODE", "true") + monkeypatch.setattr("litellm.proxy.common_utils.fips.openssl_enforces_fips", lambda: True) + monkeypatch.setattr(ProxyStartupEvent, "setup_prisma_client", AsyncMock(return_value=MockPrisma())) + scrypt_refused = ValueError("[digital envelope routines] unsupported") + monkeypatch.setattr( + "litellm.proxy.proxy_server.migrate_passwords_to_scrypt_async", AsyncMock(side_effect=scrypt_refused) + ) + + with pytest.raises(FipsModeError, match="plaintext passwords stay unhashed") as refused: + async with proxy_startup_event(FastAPI()): + pass + + assert refused.value.__cause__ is scrypt_refused + + class _DatabaseWithOneStoredCredential: def __init__(self, ciphertext): self._ciphertext = ciphertext