fix(otel): gate dynamic langfuse host behind LANGFUSE_ALLOWED_DYNAMIC_HOSTS allowlist
Some checks failed
Unit Tests: Security / security (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled

This commit is contained in:
michelligabriele 2026-06-11 22:19:00 +02:00
parent bedee5d026
commit e843b7af6c
No known key found for this signature in database
2 changed files with 145 additions and 41 deletions

View file

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

View file

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