diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 983c3d24513..fd13d0c5c45 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -25,6 +25,21 @@ def validate_finite_spend(spend: float | None) -> None: ) +def validate_no_null_bytes(params: Mapping[str, str | None]) -> None: + """Reject null bytes in string params before they reach the DB. + + Postgres rejects strings containing ``\\x00`` outright, and the raw + driver error used to reach management endpoint clients in a 500 body, + leaking database internals (issue #46076). + """ + for name, value in params.items(): + if value is not None and "\x00" in value: + raise HTTPException( + status_code=400, + detail={"error": f"{name} must not contain null bytes"}, + ) + + def validate_budget_duration(budget_duration: str | None, status_code: int = 400) -> None: """Reject budget durations that can't be parsed, are non-positive, or overflow date math, so a bad value can't be persisted and later crash the diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d4a0a35d9eb..f0f56b75c2e 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -99,6 +99,7 @@ from litellm.proxy.management_endpoints.common_utils import ( # noqa: F401 # l user_api_key_has_admin_view, validate_budget_duration, validate_finite_spend, + validate_no_null_bytes, ) from litellm.proxy.management_endpoints.key_budget_change import ( KeyBudgetAdminOnly, @@ -4973,6 +4974,8 @@ async def info_key_fn_v2( ) # Resolve key_aliases to tokens so we never pass token=None (unbounded query) tokens_to_query: Final = list(data.keys) if data.keys else [] + for token in tokens_to_query: + validate_no_null_bytes({"keys": token}) if data.key_aliases: alias_rows: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).find_many( where={"key_alias": {"in": data.key_aliases}}, @@ -5096,6 +5099,8 @@ async def info_key_fn( prisma_client, ) + validate_no_null_bytes({"key": key}) + try: if prisma_client is None: raise Exception( diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 0348d8e1f82..f7e8e5d31f4 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3463,8 +3463,19 @@ async def view_spend_logs( -H "Authorization: Bearer $LITELLM_MASTER_KEY" ``` """ + from litellm.proxy.management_endpoints.common_utils import validate_no_null_bytes from litellm.proxy.proxy_server import prisma_client + validate_no_null_bytes( + { + "api_key": api_key, + "user_id": user_id, + "request_id": request_id, + "start_date": start_date, + "end_date": end_date, + } + ) + if ( user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER or user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY @@ -3587,8 +3598,9 @@ async def view_spend_logs( ) elif isinstance(e, ProxyException): raise e + verbose_proxy_logger.exception("Unhandled error in /spend/logs") raise ProxyException( - message="/spend/logs Error" + str(e), + message="Internal server error from /spend/logs. Details are in the proxy server logs.", type="internal_error", param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 77dc6eddf51..908b12af439 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -23964,6 +23964,27 @@ async def test_key_generate_reads_the_team_from_the_writer_database_not_the_cach assert raised.value.message == "Team not found for team_id=1234. Non-admin users cannot create keys for non-existent teams." +def test_key_info_null_byte_key_rejected(): + """Regression test for https://github.com/BerriAI/litellm/issues/46076 + + A null byte in the key query param used to reach Prisma, whose raw + driver error came back to the caller in the 500 body. It must be + rejected up front with a 400 that carries no database internals. + """ + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="1234" + ) + try: + response = client.get("/key/info", params={"key": "test\x00"}) + + assert response.status_code == 400 + assert "null bytes" in response.text + finally: + app.dependency_overrides.pop(user_api_key_auth, None) + + def test_key_info_unexpected_database_exception_sanitized( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index c3e916d581d..a639c5e600d 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -8350,3 +8350,60 @@ def test_spend_calculate_rejection_body_is_openai_shaped(client): assert error["type"] == "invalid_request_error" assert error["param"] is None assert error["code"] == "400" + + +def test_view_spend_logs_null_byte_api_key_rejected(client): + """Regression test for https://github.com/BerriAI/litellm/issues/46076 + + A null byte in the api_key query param used to reach Prisma, whose raw + driver error came back to the caller in the 500 body. It must be + rejected up front with a 400 that carries no database internals. + """ + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get("/spend/logs", params={"api_key": "test\x00"}) + + assert response.status_code == 400 + assert "null bytes" in response.text + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_view_spend_logs_db_error_returns_generic_message(client, monkeypatch): + """Regression test for https://github.com/BerriAI/litellm/issues/46076 + + When the database does fail under /spend/logs, the raw driver error + must stay in the server logs and the 500 body must carry a generic + message instead of database internals. + """ + + class RaisingDB: + def __init__(self): + self.litellm_spendlogs = self + + async def find_many(self, *args, **kwargs): + raise Exception('Postgres rejected "test": invalid byte sequence for encoding "UTF8"') + + async def query_raw(self, *args, **kwargs): + raise Exception('Postgres rejected "test": invalid byte sequence for encoding "UTF8"') + + class RaisingPrismaClient: + def __init__(self): + self.db = RaisingDB() + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", RaisingPrismaClient() + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get("/spend/logs", params={"api_key": "sk-normal"}) + + assert response.status_code == 500 + assert "Postgres" not in response.text + assert "server logs" in response.text + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None)