diff --git a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py index 523b669280e..05e22ef8f41 100644 --- a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py +++ b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py @@ -9,6 +9,7 @@ Logging Pass-Through Endpoints """ import base64 +import binascii import os from base64 import b64encode from typing import Final @@ -146,6 +147,27 @@ def _build_langfuse_proxy_target( return str(updated_url), custom_headers +def _extract_api_key_from_basic_auth(authorization_header: str) -> str: + """Pull the secret half out of a `Basic base64(public_key:secret_key)` header. + + Returns "" for every shape that does not carry one, rather than raising: a + missing header, a value that is not base64, base64 that is not utf-8, and a + decoded value with no ":" in it. `user_api_key_auth` then rejects the empty + key with the normal 401, instead of the route raising IndexError (or + binascii.Error, or UnicodeDecodeError) out of an unauthenticated request and + turning it into a 500 with a traceback in the proxy log. + """ + encoded: Final = authorization_header.removeprefix("Basic ").strip() + if not encoded: + return "" + try: + decoded_str = base64.b64decode(encoded).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return "" + _, separator, secret_key = decoded_str.partition(":") + return secret_key if separator else "" + + @router.api_route( "/langfuse/{endpoint:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], @@ -164,14 +186,8 @@ async def langfuse_proxy_route( from litellm.proxy.proxy_server import proxy_config ## CHECK FOR LITELLM API KEY IN THE QUERY PARAMS - ?..key=LITELLM_API_KEY - api_key = request.headers.get("Authorization") or "" - - ## decrypt base64 hash - api_key = api_key.replace("Basic ", "") - - decoded_bytes: Final = base64.b64decode(api_key) - decoded_str: Final = decoded_bytes.decode("utf-8") - api_key = decoded_str.split(":")[1] # assume api key is passed in as secret key + ## decrypt base64 hash; the api key is passed in as the secret key + api_key: Final = _extract_api_key_from_basic_auth(request.headers.get("Authorization") or "") user_api_key_dict: Final = await user_api_key_auth(request=request, api_key=f"Bearer {api_key}") diff --git a/tests/test_litellm/proxy/test_langfuse_passthrough_security.py b/tests/test_litellm/proxy/test_langfuse_passthrough_security.py index 5ef3c38c09d..bdab45498b2 100644 --- a/tests/test_litellm/proxy/test_langfuse_passthrough_security.py +++ b/tests/test_litellm/proxy/test_langfuse_passthrough_security.py @@ -1,4 +1,5 @@ import socket +from base64 import b64encode import pytest from fastapi import HTTPException @@ -6,6 +7,7 @@ from fastapi import HTTPException import litellm from litellm.proxy.vertex_ai_endpoints.langfuse_endpoints import ( _build_langfuse_proxy_target, + _extract_api_key_from_basic_auth, _get_langfuse_proxy_credentials, ) @@ -100,3 +102,64 @@ def test_dynamic_langfuse_proxy_target_preserves_host_header_for_http(monkeypatc assert target_url == "http://8.8.8.8/api/public/projects" assert headers["Host"] == "langfuse.example" + + +@pytest.mark.parametrize( + "authorization_header, expected", + [ + # Every shape below except the two valid ones raised out of the route + # before, turning an unauthenticated request into a 500 with a traceback. + ("", ""), # no Authorization header at all + ("Basic ", ""), # header present, credentials empty + ("Basic " + b64encode(b"pk-lf-1:sk-lf-2").decode(), "sk-lf-2"), + (b64encode(b"pk-lf-1:sk-lf-2").decode(), "sk-lf-2"), # bare base64, no scheme + ("Basic " + b64encode(b"sk-lf-2").decode(), ""), # no ":" to split on + ("Basic YWJj=", ""), # not decodable base64 + ("Basic //4=", ""), # decodes to bytes that are not utf-8 + ("Bearer sk-1234", ""), # wrong scheme + ], +) +def test_extract_api_key_from_basic_auth_never_raises(authorization_header, expected): + assert _extract_api_key_from_basic_auth(authorization_header) == expected + + +def test_extract_api_key_keeps_a_secret_containing_a_colon(): + """RFC 7617 puts everything after the first ":" in the password, so a secret + with a colon in it must survive whole. `split(":")[1]` truncated it to the + first segment, which then failed authentication for a non-obvious reason.""" + header = "Basic " + b64encode(b"pk-lf-1:sk:with:colons").decode() + + assert _extract_api_key_from_basic_auth(header) == "sk:with:colons" + + +@pytest.mark.asyncio +async def test_langfuse_route_hands_missing_credentials_to_the_authenticator(monkeypatch): + """An unauthenticated request must reach user_api_key_auth and be rejected + there, rather than raising IndexError before authentication runs.""" + from fastapi import Request + + from litellm.proxy.vertex_ai_endpoints import langfuse_endpoints + + seen: dict = {} + + async def fake_user_api_key_auth(request, api_key): + seen["api_key"] = api_key + raise HTTPException(status_code=401, detail={"error": "Authentication Error"}) + + monkeypatch.setattr(langfuse_endpoints, "user_api_key_auth", fake_user_api_key_auth) + + request = Request( + { + "type": "http", + "method": "GET", + "path": "/langfuse/zzz", + "headers": [], # the reported case: no Authorization header + "query_string": b"", + } + ) + + with pytest.raises(HTTPException) as exc: + await langfuse_endpoints.langfuse_proxy_route(endpoint="zzz", request=request, fastapi_response=None) + + assert exc.value.status_code == 401 + assert seen["api_key"] == "Bearer "