diff --git a/litellm/constants.py b/litellm/constants.py index 61febbb4779..5d15eb25212 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -364,11 +364,12 @@ MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY: Final = "litellm.model_group_alias_resolve REALTIME_SESSION_SUCCESS_LOGGED_KEY: Final = "realtime_session_success_logged" REALTIME_SESSION_FAILURE_LOGGED_KEY: Final = "realtime_session_failure_logged" -FIPS_SSL_CIPHERS: Final = os.getenv( - "LITELLM_SSL_CIPHERS", +# Certified GCM-only list: deliberately NOT overridable via LITELLM_SSL_CIPHERS so a +# custom override cannot silently weaken the FIPS posture when LITELLM_FIPS_MODE is set. +FIPS_SSL_CIPHERS: Final = ( "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:" "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:" - "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256" ) DEFAULT_SSL_CIPHERS: Final = os.getenv( "LITELLM_SSL_CIPHERS", diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 3382ab278f6..537fa18fad4 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -286,7 +286,13 @@ _ssl_context_cache: Final[dict[tuple[str | None, str | None, str | None, str], s def _outbound_ssl_ciphers() -> str: from litellm.proxy.common_utils.fips import is_fips_mode - return FIPS_SSL_CIPHERS if is_fips_mode() else DEFAULT_SSL_CIPHERS + if is_fips_mode(): + if os.getenv("LITELLM_SSL_CIPHERS"): + verbose_logger.warning( + "LITELLM_SSL_CIPHERS override ignored in FIPS mode; using FIPS_SSL_CIPHERS" + ) + return FIPS_SSL_CIPHERS + return DEFAULT_SSL_CIPHERS def _create_ssl_context( diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index 04d7dfb2d66..280941ea9e0 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -366,6 +366,80 @@ def test_get_ssl_configuration_uses_fips_cipher_list_only_in_fips_mode(monkeypat assert get_ssl_configuration() is fips_context +_CHACHA_OVERRIDE: Final = "ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-SHA256" + + +def _simulate_cipher_override_at_import(monkeypatch: pytest.MonkeyPatch, http_handler) -> None: + """Simulate a process that started with LITELLM_SSL_CIPHERS set: litellm.constants + re-reads the env at import and http_handler binds those values at import. Reloading + http_handler itself would rebind its classes and break isinstance in later tests, so + only constants is re-executed and the handler's bindings are swapped explicitly.""" + from litellm import constants + + importlib.reload(constants) + monkeypatch.setattr(http_handler, "DEFAULT_SSL_CIPHERS", constants.DEFAULT_SSL_CIPHERS) + monkeypatch.setattr(http_handler, "FIPS_SSL_CIPHERS", constants.FIPS_SSL_CIPHERS) + + +def _restore_cipher_constants(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm import constants + + monkeypatch.delenv("LITELLM_SSL_CIPHERS", raising=False) + importlib.reload(constants) + + +def test_get_ssl_configuration_ignores_ssl_ciphers_override_in_fips_mode( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + import logging + + from litellm.llms.custom_httpx import http_handler + + monkeypatch.setattr(http_handler, "_ssl_context_cache", {}) + monkeypatch.setenv("LITELLM_SSL_CIPHERS", _CHACHA_OVERRIDE) + monkeypatch.setenv("LITELLM_FIPS_MODE", "true") + monkeypatch.delenv("SSL_SECURITY_LEVEL", raising=False) + monkeypatch.delenv("SSL_ECDH_CURVE", raising=False) + + _simulate_cipher_override_at_import(monkeypatch, http_handler) + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + context = http_handler.get_ssl_configuration() + _restore_cipher_constants(monkeypatch) + + assert isinstance(context, ssl.SSLContext) + names = _context_tls12_cipher_names(context) + supported = _supported_tls12_cipher_names() + assert names == (_tls12_cipher_names(http_handler.FIPS_SSL_CIPHERS) & supported) + assert not any("CHACHA" in name for name in names) + assert "LITELLM_SSL_CIPHERS override ignored" in caplog.text + + +def test_get_ssl_configuration_applies_ssl_ciphers_override_outside_fips_mode( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + import logging + + from litellm.llms.custom_httpx import http_handler + + monkeypatch.setattr(http_handler, "_ssl_context_cache", {}) + monkeypatch.setenv("LITELLM_SSL_CIPHERS", _CHACHA_OVERRIDE) + monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False) + monkeypatch.delenv("SSL_SECURITY_LEVEL", raising=False) + monkeypatch.delenv("SSL_ECDH_CURVE", raising=False) + + _simulate_cipher_override_at_import(monkeypatch, http_handler) + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + context = http_handler.get_ssl_configuration() + _restore_cipher_constants(monkeypatch) + + assert isinstance(context, ssl.SSLContext) + names = _context_tls12_cipher_names(context) + supported = _supported_tls12_cipher_names() + assert names == (_tls12_cipher_names(_CHACHA_OVERRIDE) & supported) + assert any("CHACHA" in name for name in names) + assert "LITELLM_SSL_CIPHERS override ignored" not in caplog.text + + # Session Reuse Tests class MockClientSession: """Mock ClientSession that is not callable""" @@ -1532,7 +1606,6 @@ async def test_connection_error_retry_forwards_content(method: str): await handler.close() - @pytest.fixture def forward_proxy_server(): """Plain HTTP forward proxy that records the absolute URIs it is asked to fetch.""" @@ -1927,6 +2000,7 @@ def _vcr_outcome_gate(request, vcr): yield record_vcr_outcome(request, vcr) + @pytest.fixture(scope="function") def isolate_litellm_state(): """ @@ -1979,6 +2053,7 @@ def isolate_litellm_state(): setattr(litellm, attr, original_value) _invalidate_model_cost_lowercase_map() + _SCALAR_DEFAULTS = { "num_retries": getattr(litellm, "num_retries", None), "num_retries_per_request": getattr(litellm, "num_retries_per_request", None), @@ -1999,6 +2074,7 @@ _SCALAR_DEFAULTS = { "api_key": getattr(litellm, "api_key", None), } + @pytest.fixture(scope="module") def setup_and_teardown(): """ @@ -2021,12 +2097,14 @@ def setup_and_teardown(): litellm.in_memory_llm_clients_cache.flush_cache() yield + _SERVER_DELAY_S = 5 _PER_REQUEST_TIMEOUT_S = 1.0 _CLIENT_DEFAULT_TIMEOUT_S = 60.0 + class _SlowHandler(BaseHTTPRequestHandler): def do_POST(self): time.sleep(_SERVER_DELAY_S) @@ -2040,6 +2118,7 @@ class _SlowHandler(BaseHTTPRequestHandler): def log_message(self, *args): pass + @pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown") def test_post_delay_exceeds_per_request_timeout_raises(): server = ThreadingHTTPServer(("127.0.0.1", 0), _SlowHandler) diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index f1586a27664..10f693ec45d 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -96,12 +96,15 @@ def test_fips_ssl_ciphers_tls12_suites_are_all_gcm(): assert all("-GCM-" in suite for suite in tls12_suites) -def test_ssl_ciphers_env_override_applies_to_both_lists(monkeypatch: pytest.MonkeyPatch): +def test_ssl_ciphers_env_override_applies_to_default_list_only(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("LITELLM_SSL_CIPHERS", "ECDHE-RSA-AES128-GCM-SHA256") importlib.reload(constants) try: assert constants.DEFAULT_SSL_CIPHERS == "ECDHE-RSA-AES128-GCM-SHA256" - assert constants.FIPS_SSL_CIPHERS == "ECDHE-RSA-AES128-GCM-SHA256" + # The FIPS list must stay certified regardless of the override so FIPS mode + # cannot be silently weakened by LITELLM_SSL_CIPHERS. + assert constants.FIPS_SSL_CIPHERS != "ECDHE-RSA-AES128-GCM-SHA256" + assert all("-GCM-" in suite for suite in _cipher_suites(constants.FIPS_SSL_CIPHERS) if not suite.startswith("TLS_")) finally: monkeypatch.delenv("LITELLM_SSL_CIPHERS") importlib.reload(constants)