fix(otel v2): require the langfuse key pair before a dynamic host moves the endpoint
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

langfuse_dynamic_headers only builds routing headers when the dynamic params
carry both langfuse_public_key and langfuse_secret_key, but the new endpoint
resolver honored langfuse_host on its own. A key or team that set only a host
and reached tenant routing another way (an otel_service_name, for example) got
its host stamped onto the exporter while the exporter kept the operator's
env-derived Authorization header, so the operator's Langfuse credentials were
POSTed to a caller-named destination. Verified on a live proxy: the OTLP leg
reached the caller's host with the operator's Basic header before this change
and does not after it, while a team that supplies its own key pair alongside
its host still routes to that host with its own credentials.
This commit is contained in:
Yucheng Zhu 2026-09-01 01:08:55 -07:00
parent cc0d56a99b
commit e3a2993202
3 changed files with 55 additions and 3 deletions

View file

@ -236,8 +236,9 @@ class TenantTracerCache:
service_name: Final = tenant_service_name(auth_metadata)
if not credential_headers and not project_headers and service_name is None:
return TenantRoute(tracer=default, detached=False)
# A fixed per-integration region endpoint (New Relic us/eu), never a
# caller-supplied host; ``None`` keeps the preset's own endpoint.
# A fixed per-integration region endpoint (New Relic us/eu), or the host
# the key or team was configured with, which its own credentials come
# with; never a host off the request. ``None`` keeps the preset's endpoint.
endpoint: Final = dynamic_otlp_endpoint(self._callback_name, dynamic_params)
cache_key: Final = (
tuple(sorted(credential_headers.items())),

View file

@ -54,8 +54,14 @@ def langfuse_dynamic_endpoint(params: StandardCallbackDynamicParams) -> str | No
``None`` means the request does not move the destination, so the preset's
env-resolved endpoint stands (V1 parity: ``construct_dynamic_otel_config``
falls back to the env host when the dynamic params carry no ``langfuse_host``).
A host only counts alongside the key pair it belongs to, which is the same
precondition ``langfuse_dynamic_headers`` applies and the same one V1 applies.
A host on its own would move the endpoint while the headers builder returned
nothing, so the exporter would keep the operator's env-derived Authorization
header and post it to the caller's host.
"""
host: Final = params.get("langfuse_host")
if not host:
if not host or not params.get("langfuse_public_key") or not params.get("langfuse_secret_key"):
return None
return _V1Langfuse.get_langfuse_otel_endpoint(host)

View file

@ -713,6 +713,51 @@ def test_langfuse_dynamic_endpoint_is_none_without_a_host():
assert dynamic_otlp_endpoint("langfuse_otel", LANGFUSE_CREDS) is None
@pytest.mark.parametrize(
"partial",
[
{},
{"langfuse_public_key": "pk"},
{"langfuse_secret_key": "sk"},
],
)
def test_langfuse_dynamic_endpoint_is_none_without_the_key_pair(partial):
"""A host without both keys must not move the destination.
The headers builder needs both keys, so a host on its own would leave the
exporter carrying the operator's env-derived Authorization header while
pointing it at the caller's host. V1 returns None in the same state.
"""
assert dynamic_otlp_endpoint("langfuse_otel", {**partial, "langfuse_host": "attacker.example.com"}) is None
def test_operator_credentials_stay_on_the_operator_endpoint():
"""A team that sets only a host, and reaches routing via its service name,
must not redirect the exporter that still carries the operator's header."""
cache = _cache(
"langfuse_otel",
exporters=[
ExporterSpec(
kind="otlp_http",
endpoint="http://env-host:3100/api/public/otel",
headers="Authorization=Basic operator-env",
owner="langfuse_otel",
)
],
)
params = {"langfuse_host": "attacker.example.com"}
new_cfg = cache._routed_config(
cache._credential_headers(params),
{},
dynamic_otlp_endpoint("langfuse_otel", params),
"team-a",
)
exporter = new_cfg.exporters[0]
assert exporter.endpoint == "http://env-host:3100/api/public/otel"
assert exporter.headers == "Authorization=Basic operator-env"
assert new_cfg.service_name == "team-a"
def test_langfuse_host_stamped_onto_owned_exporter_only():
"""A Langfuse key's host must never repoint a co-configured exporter owned by
a different backend."""