From e843b7af6c56545ce6ba88d4cc7804c64fa24331 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Thu, 11 Jun 2026 22:19:00 +0200 Subject: [PATCH] fix(otel): gate dynamic langfuse host behind LANGFUSE_ALLOWED_DYNAMIC_HOSTS allowlist --- .../integrations/langfuse/langfuse_otel.py | 68 ++++++---- .../integrations/test_langfuse_otel.py | 118 +++++++++++++++--- 2 files changed, 145 insertions(+), 41 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index e232d6d282a..2e05dd8a575 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -283,9 +283,9 @@ class LangfuseOtelLogger(OpenTelemetry): if langfuse_host: # If LANGFUSE_HOST is provided, construct OTEL endpoint from it - endpoint = LangfuseOtelLogger._construct_langfuse_otel_endpoint( - langfuse_host - ) + if not langfuse_host.startswith("http"): + langfuse_host = "https://" + langfuse_host + endpoint = f"{langfuse_host.rstrip('/')}/api/public/otel" verbose_logger.debug(f"Using Langfuse OTEL endpoint from host: {endpoint}") else: # Default to US cloud endpoint @@ -332,9 +332,9 @@ class LangfuseOtelLogger(OpenTelemetry): if langfuse_host: # If LANGFUSE_HOST is provided, construct OTEL endpoint from it - endpoint = LangfuseOtelLogger._construct_langfuse_otel_endpoint( - langfuse_host - ) + if not langfuse_host.startswith("http"): + langfuse_host = "https://" + langfuse_host + endpoint = f"{langfuse_host.rstrip('/')}/api/public/otel" verbose_logger.debug(f"Using Langfuse OTEL endpoint from host: {endpoint}") else: # Default to US cloud endpoint @@ -365,13 +365,6 @@ class LangfuseOtelLogger(OpenTelemetry): auth_header = base64.b64encode(auth_string.encode()).decode() return f"Basic {auth_header}" - @staticmethod - def _construct_langfuse_otel_endpoint(langfuse_host: str) -> str: - """Build the Langfuse OTLP base endpoint from a host (scheme-tolerant).""" - if not langfuse_host.startswith("http"): - langfuse_host = "https://" + langfuse_host - return f"{langfuse_host.rstrip('/')}/api/public/otel" - def construct_dynamic_otel_headers( self, standard_callback_dynamic_params: StandardCallbackDynamicParams ) -> Optional[dict]: @@ -407,21 +400,54 @@ class LangfuseOtelLogger(OpenTelemetry): Construct a per-key/team Langfuse OTLP endpoint from the dynamic host. Per-key Langfuse credentials are only valid against the host that issued - them, so the host must travel with the credentials. Returns None when no - per-key host is set, falling back to the env-configured endpoint. + them, so the host must travel with the credentials. Returns None when the + dynamic host is unset or not allowed, falling back to the env-configured + endpoint. - Prefers ``langfuse_base_url`` (the Langfuse v3 naming) and falls back to - the deprecated ``langfuse_host`` for backward compatibility, mirroring the - SDK's own ``base_url`` -> ``host`` resolution order. + Security: callback vars can be supplied on requests, so a dynamic host is + only honored when the proxy operator has explicitly allow-listed it in the + ``LANGFUSE_ALLOWED_DYNAMIC_HOSTS`` env var (comma-separated base URLs, e.g. + ``https://us.cloud.langfuse.com,https://langfuse.internal.example``). When + unset, dynamic hosts are ignored entirely — the exporter destination is + always operator-controlled via the env. + + The dynamic host must be a fully-qualified ``http(s)://`` base URL; it is + matched against the allowlist as-is (modulo trailing slashes) and never + rewritten. Prefers ``langfuse_base_url`` (the Langfuse v3 naming) and falls + back to the deprecated ``langfuse_host``, mirroring the SDK's own + ``base_url`` -> ``host`` resolution order. """ dynamic_langfuse_base_url = standard_callback_dynamic_params.get( "langfuse_base_url" ) or standard_callback_dynamic_params.get("langfuse_host") if not dynamic_langfuse_base_url: return None - return LangfuseOtelLogger._construct_langfuse_otel_endpoint( - dynamic_langfuse_base_url - ) + + normalized_base_url = dynamic_langfuse_base_url.strip().rstrip("/") + if not normalized_base_url.startswith(("http://", "https://")): + verbose_logger.warning( + "Ignoring dynamic langfuse host %r: must be a fully-qualified " + "http(s):// base URL. Falling back to the env-configured Langfuse " + "OTEL endpoint.", + dynamic_langfuse_base_url, + ) + return None + + allowed_hosts = [ + host.strip().rstrip("/") + for host in os.environ.get("LANGFUSE_ALLOWED_DYNAMIC_HOSTS", "").split(",") + if host.strip() + ] + if normalized_base_url not in allowed_hosts: + verbose_logger.warning( + "Ignoring dynamic langfuse host %r: not present in the " + "LANGFUSE_ALLOWED_DYNAMIC_HOSTS env var. Falling back to the " + "env-configured Langfuse OTEL endpoint.", + normalized_base_url, + ) + return None + + return f"{normalized_base_url}/api/public/otel" def create_litellm_proxy_request_started_span( self, diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index acadeda3030..00ffc77aa1a 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -392,22 +392,90 @@ class TestLangfuseOtelIntegration: # Should return an empty dict assert result == {} - def test_construct_dynamic_otel_endpoint_with_host(self): - """Per-key langfuse_host is turned into a normalized OTLP base endpoint.""" + def test_construct_dynamic_otel_endpoint_with_allowlisted_host(self): + """An allow-listed per-key langfuse_host is turned into the OTLP base endpoint.""" from litellm.types.utils import StandardCallbackDynamicParams logger = LangfuseOtelLogger() - eu = logger.construct_dynamic_otel_endpoint( - StandardCallbackDynamicParams(langfuse_host="https://cloud.langfuse.com") - ) - assert eu == "https://cloud.langfuse.com/api/public/otel" + with patch.dict( + os.environ, + {"LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "https://cloud.langfuse.com"}, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_host="https://cloud.langfuse.com" + ) + ) + assert endpoint == "https://cloud.langfuse.com/api/public/otel" - # scheme-less + trailing slash normalize identically to the env path - no_scheme = logger.construct_dynamic_otel_endpoint( - StandardCallbackDynamicParams(langfuse_host="us.cloud.langfuse.com/") - ) - assert no_scheme == "https://us.cloud.langfuse.com/api/public/otel" + def test_construct_dynamic_otel_endpoint_trailing_slash_tolerated(self): + """Trailing slashes on the dynamic host or allowlist entries don't break matching.""" + from litellm.types.utils import StandardCallbackDynamicParams + + logger = LangfuseOtelLogger() + + with patch.dict( + os.environ, + { + "LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "https://eu.cloud.langfuse.com, https://us.cloud.langfuse.com/" + }, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_host="https://us.cloud.langfuse.com/" + ) + ) + assert endpoint == "https://us.cloud.langfuse.com/api/public/otel" + + def test_construct_dynamic_otel_endpoint_denied_when_allowlist_unset(self): + """No operator allowlist -> dynamic hosts are ignored (SSRF guard).""" + from litellm.types.utils import StandardCallbackDynamicParams + + logger = LangfuseOtelLogger() + + env_without_allowlist = { + k: v for k, v in os.environ.items() if k != "LANGFUSE_ALLOWED_DYNAMIC_HOSTS" + } + with patch.dict(os.environ, env_without_allowlist, clear=True): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_host="https://attacker.internal.example" + ) + ) + assert endpoint is None + + def test_construct_dynamic_otel_endpoint_denied_when_host_not_allowlisted(self): + """A dynamic host outside the allowlist -> ignored, env endpoint used (SSRF guard).""" + from litellm.types.utils import StandardCallbackDynamicParams + + logger = LangfuseOtelLogger() + + with patch.dict( + os.environ, + {"LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "https://us.cloud.langfuse.com"}, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_host="https://attacker.internal.example" + ) + ) + assert endpoint is None + + def test_construct_dynamic_otel_endpoint_requires_full_url(self): + """Scheme-less dynamic hosts are rejected, never rewritten.""" + from litellm.types.utils import StandardCallbackDynamicParams + + logger = LangfuseOtelLogger() + + with patch.dict( + os.environ, + {"LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "us.cloud.langfuse.com"}, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams(langfuse_host="us.cloud.langfuse.com") + ) + assert endpoint is None def test_construct_dynamic_otel_endpoint_without_host(self): """No per-key host -> None, so the env endpoint is used.""" @@ -424,11 +492,15 @@ class TestLangfuseOtelIntegration: from litellm.types.utils import StandardCallbackDynamicParams logger = LangfuseOtelLogger() - endpoint = logger.construct_dynamic_otel_endpoint( - StandardCallbackDynamicParams( - langfuse_base_url="https://us.cloud.langfuse.com" + with patch.dict( + os.environ, + {"LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "https://us.cloud.langfuse.com"}, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_base_url="https://us.cloud.langfuse.com" + ) ) - ) assert endpoint == "https://us.cloud.langfuse.com/api/public/otel" def test_construct_dynamic_otel_endpoint_base_url_preferred_over_host(self): @@ -436,12 +508,18 @@ class TestLangfuseOtelIntegration: from litellm.types.utils import StandardCallbackDynamicParams logger = LangfuseOtelLogger() - endpoint = logger.construct_dynamic_otel_endpoint( - StandardCallbackDynamicParams( - langfuse_base_url="https://us.cloud.langfuse.com", - langfuse_host="https://cloud.langfuse.com", + with patch.dict( + os.environ, + { + "LANGFUSE_ALLOWED_DYNAMIC_HOSTS": "https://us.cloud.langfuse.com,https://cloud.langfuse.com" + }, + ): + endpoint = logger.construct_dynamic_otel_endpoint( + StandardCallbackDynamicParams( + langfuse_base_url="https://us.cloud.langfuse.com", + langfuse_host="https://cloud.langfuse.com", + ) ) - ) assert endpoint == "https://us.cloud.langfuse.com/api/public/otel" def test_get_langfuse_otel_config_with_otel_host_priority(self):