Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion netra/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import logging
import os
from typing import Any, Dict, FrozenSet, List, Optional
from typing import Any, Dict, FrozenSet, List, Optional, Tuple

from opentelemetry.util.re import parse_env_headers

Expand Down Expand Up @@ -121,6 +121,8 @@ def __init__(

self.redact_headers = self._get_redact_headers()

self.propagate_baggage_hosts = self._get_propagate_baggage_hosts()

self._resolve_audio_settings()

self._set_trace_content_env()
Expand Down Expand Up @@ -334,6 +336,12 @@ def _get_redact_headers(self) -> FrozenSet[str]:
env_value = os.getenv("NETRA_REDACT_HEADERS", "")
return frozenset(name.strip().lower() for name in env_value.split(",") if name.strip())

def _get_propagate_baggage_hosts(self) -> Tuple[str, ...]:
"""Resolve the internal-host allowlist from ``NETRA_PROPAGATE_BAGGAGE_HOSTS`` (comma-separated); entries are lowercased with leading dots stripped, empty when unset (fail-closed)."""
raw = os.getenv("NETRA_PROPAGATE_BAGGAGE_HOSTS", "").split(",")
normalized = (host.strip().lstrip(".").lower() for host in raw)
return tuple(host for host in normalized if host)

def _get_int_config(self, param: Optional[int], env_var: str, default: int) -> int:
"""Get integer configuration from parameter or environment variable."""
if param is not None:
Expand Down
81 changes: 81 additions & 0 deletions netra/instrumentation/http/propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Host-scoped context propagation for Netra's HTTP instrumentation.

Netra stores the session identity (``session_id``/``user_id``/``tenant_id`` and
any custom session keys) as W3C baggage. The default OpenTelemetry global
propagator serialises baggage into a ``baggage:`` header on *every* outbound
request, so a bare :func:`opentelemetry.propagate.inject` leaks that identity to
third-party APIs (LLM providers included).

:func:`inject_context` is the single injection entry point used by all of
Netra's HTTP client wrappers. It injects the full context as before (trace
context, and whatever else the global propagator carries) and then removes the
``baggage`` header entirely unless the destination host is on an explicit
internal allowlist. Trace context (``traceparent``/``tracestate``) is always
propagated; the whole W3C ``baggage`` header is host-gated -- Netra's session
identity lives there, so gating the header is what closes the leak, and any
other baggage a caller may have set is gated with it rather than being allowed
to reach third parties.

The allowlist is fail-closed: with no hosts configured, baggage propagates to
*nobody*, which closes the leak with zero configuration. Internal services are
opted back in via the ``NETRA_PROPAGATE_BAGGAGE_HOSTS`` environment variable.
"""

import logging
from typing import MutableMapping, Optional
from urllib.parse import urlparse

from opentelemetry.propagate import inject

from netra.config import get_active_config

logger = logging.getLogger(__name__)

# OpenTelemetry's W3CBaggagePropagator always writes this (lowercase) header.
# We match case-insensitively when stripping, to be safe against carriers that
# normalise header casing differently.
_BAGGAGE_HEADER = "baggage"


def _host_allowed(url: Optional[str]) -> bool:
"""Return True when *url*'s host is on the configured internal allowlist.

Fail-closed: returns False when *url* is missing/unparseable, when no
allowlist is configured, or when nothing matches. A host matches an
allowlist entry when it equals the entry or is a subdomain of it (so
``mycorp.net`` matches ``api.mycorp.net`` but not ``notmycorp.net``).
"""
cfg = get_active_config()
allow = getattr(cfg, "propagate_baggage_hosts", ()) if cfg is not None else ()
if not url or not allow:
return False
try:
# rstrip(".") normalizes a fully-qualified trailing-dot host ("svc.corp.net.")
# so it matches the allowlist and cannot dodge the "." + entry suffix boundary.
host = (urlparse(url).hostname or "").lower().rstrip(".")
except ValueError:
return False
if not host:
return False
return any(host == entry or host.endswith("." + entry) for entry in allow)


def inject_context(carrier: MutableMapping[str, str], url: Optional[str] = None) -> None:
"""Inject propagation headers into *carrier*, host-gating session baggage.

Trace context is always injected. The W3C ``baggage`` header -- which
carries Netra's session identity -- is removed unless *url*'s host is on the
internal allowlist (see :func:`_host_allowed`).

Args:
carrier: The header mapping to inject into (mutated in place).
url: The outbound request URL, used to decide whether baggage may be
propagated. When ``None`` the host is treated as untrusted and
baggage is stripped.
"""
inject(carrier)
if _host_allowed(url):
return
# Strip session-identity baggage for untrusted/unknown hosts.
for key in [k for k in carrier if isinstance(k, str) and k.lower() == _BAGGAGE_HEADER]:
carrier.pop(key, None)
15 changes: 13 additions & 2 deletions netra/instrumentation/libraries/aiohttp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
suppress_http_instrumentation,
)
from opentelemetry.metrics import Histogram, get_meter
from opentelemetry.propagate import inject
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
from opentelemetry.semconv.attributes.network_attributes import (
NETWORK_PEER_ADDRESS,
Expand All @@ -58,6 +57,7 @@
)
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection

from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.aiohttp.version import __version__

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -182,7 +182,18 @@ async def instrumented_request(self: ClientSession, method: str, url: Any, **kwa
else:
headers_dict = dict(headers)

inject(headers_dict)
# Resolve against the session base_url so a relative path
# (ClientSession(base_url=...) + session.get("/x")) still exposes the
# real destination host to the baggage-propagation allowlist check.
propagate_url = url
base_url = getattr(self, "_base_url", None)
if base_url is not None:
try:
propagate_url = str(base_url.join(URL(url_str)))
except Exception:
propagate_url = url

inject_context(headers_dict, propagate_url)
kwargs["headers"] = headers_dict

with suppress_http_instrumentation():
Expand Down
18 changes: 5 additions & 13 deletions netra/instrumentation/libraries/httpx/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from opentelemetry import context as context_api
from opentelemetry.instrumentation.utils import suppress_http_instrumentation
from opentelemetry.propagate import inject
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from wrapt import ObjectProxy

from netra.instrumentation.http.body import new_body_buffer
from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.httpx.utils import (
get_default_span_name,
set_span_input,
Expand Down Expand Up @@ -326,9 +326,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
) as span:
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -362,9 +360,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -444,9 +440,7 @@ async def wrapper(
) as span:
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down Expand Up @@ -479,9 +473,7 @@ async def wrapper(
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
headers = dict(request.headers)
inject(headers)
request.headers.update(headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.httpx: failed to set span input: %s", e)

Expand Down
6 changes: 3 additions & 3 deletions netra/instrumentation/libraries/requests/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from opentelemetry import context as context_api
from opentelemetry.instrumentation.utils import suppress_http_instrumentation
from opentelemetry.propagate import inject
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from wrapt import ObjectProxy

from netra.instrumentation.http.body import new_body_buffer
from netra.instrumentation.http.propagation import inject_context
from netra.instrumentation.libraries.requests.utils import (
get_default_span_name,
set_span_input,
Expand Down Expand Up @@ -189,7 +189,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
) as span:
try:
set_span_input(span, request)
inject(request.headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.requests: failed to set span input: %s", e)

Expand Down Expand Up @@ -223,7 +223,7 @@ def wrapper(wrapped: Callable[..., Any], instance: Any, args: Tuple[Any, ...], k
context = context_api.attach(set_span_in_context(span))
try:
set_span_input(span, request)
inject(request.headers)
inject_context(request.headers, url)
except Exception as e:
logger.debug("netra.instrumentation.libraries.requests: failed to set span input: %s", e)

Expand Down