From 240cdbcc23104fa915746fdc2928da7c9b418317 Mon Sep 17 00:00:00 2001 From: RiyaSunil27 Date: Mon, 28 Sep 2026 15:18:16 +0530 Subject: [PATCH] [NET-1627] fix: Prevent Session Context from Propagating via Baggage Headers --- netra/config.py | 10 ++- netra/instrumentation/http/propagation.py | 81 +++++++++++++++++++ .../libraries/aiohttp/__init__.py | 15 +++- .../libraries/httpx/wrappers.py | 18 ++--- .../libraries/requests/wrappers.py | 6 +- 5 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 netra/instrumentation/http/propagation.py diff --git a/netra/config.py b/netra/config.py index 668d202..9d88c0a 100644 --- a/netra/config.py +++ b/netra/config.py @@ -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 @@ -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() @@ -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: diff --git a/netra/instrumentation/http/propagation.py b/netra/instrumentation/http/propagation.py new file mode 100644 index 0000000..2a7bbb2 --- /dev/null +++ b/netra/instrumentation/http/propagation.py @@ -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) diff --git a/netra/instrumentation/libraries/aiohttp/__init__.py b/netra/instrumentation/libraries/aiohttp/__init__.py index 2db96c3..8261ba1 100644 --- a/netra/instrumentation/libraries/aiohttp/__init__.py +++ b/netra/instrumentation/libraries/aiohttp/__init__.py @@ -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, @@ -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__) @@ -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(): diff --git a/netra/instrumentation/libraries/httpx/wrappers.py b/netra/instrumentation/libraries/httpx/wrappers.py index 1ce5c75..d84cd3d 100644 --- a/netra/instrumentation/libraries/httpx/wrappers.py +++ b/netra/instrumentation/libraries/httpx/wrappers.py @@ -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, @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/netra/instrumentation/libraries/requests/wrappers.py b/netra/instrumentation/libraries/requests/wrappers.py index a085474..86217e9 100644 --- a/netra/instrumentation/libraries/requests/wrappers.py +++ b/netra/instrumentation/libraries/requests/wrappers.py @@ -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, @@ -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) @@ -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)