mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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
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:
parent
bedee5d026
commit
e843b7af6c
2 changed files with 145 additions and 41 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue