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.
This commit is contained in:
yucheng 2026-10-27 10:31:54 -07:00
parent 14193435b8
commit db7c9aa546
5 changed files with 248 additions and 1 deletions

View file

@ -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}."
)

View file

@ -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 (

View file

@ -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

View file

@ -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()

View file

@ -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