From f0438d3df0ab8eca0391c230319969ff77bb7345 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 11:12:29 +0200 Subject: [PATCH 1/6] feat(wsgi): Add `sentry.is_localhost` for inbound filters (#7780) Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 1 of https://github.com/getsentry/sentry-python/issues/7727 --- sentry_sdk/consts.py | 8 +++++ sentry_sdk/integrations/wsgi.py | 13 +++++++ sentry_sdk/utils.py | 40 ++++++++++++++++++++++ tests/integrations/wsgi/test_wsgi.py | 51 ++++++++++++++++++++++++++++ tests/test_utils.py | 51 ++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e13d3bd72d..20ae39c83a 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1164,6 +1164,14 @@ class SPANDATA: Example: "prod" """ + SENTRY_IS_LOCALHOST = "sentry.is_localhost" + """ + Indicates whether a telemetry item was sent on a host, device or browser on + a localhost URL or IP address. + + Used in inbound filters. + """ + SENTRY_RELEASE = "sentry.release" """ The Sentry release. diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 3204e15e43..ab04308f6e 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -18,6 +18,7 @@ from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( ContextVar, + _is_localhost, capture_internal_exceptions, event_from_exception, has_data_collection_enabled, @@ -126,6 +127,18 @@ def __call__( ) ) + scope.set_attribute( + SPANDATA.SENTRY_IS_LOCALHOST, + _is_localhost( + client_ip=get_client_ip(environ), + url_host=environ.get("SERVER_NAME") + if not environ.get("HTTP_HOST") + else None, + host_header=environ.get("HTTP_HOST"), + forwarded_host_header=environ.get("HTTP_X_FORWARDED_HOST"), + ), + ) + method = environ.get("REQUEST_METHOD", "").upper() span_ctx: "Optional[ContextManager[Union[Span, StreamedSpan, None]]]" = None diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 2e20a789b8..3889a7d15a 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -100,6 +100,9 @@ be affected by this limit if they have a custom recursion limit. """ +_LOCAL_IPS = frozenset({"127.0.0.1", "::1"}) +_LOCAL_DOMAINS = frozenset({"localhost", "127.0.0.1"}) + def env_to_bool(value: "Any", *, strict: "Optional[bool]" = False) -> "bool | None": """Casts an ENV variable value to boolean using the constants defined above. @@ -2205,6 +2208,43 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": return {"value": safe_repr(val), "type": "string"} +def _host_matches_local_domain(host: str) -> bool: + """Check if host matches a local domain, including subdomains like foo.localhost.""" + if host in _LOCAL_DOMAINS: + return True + for domain in _LOCAL_DOMAINS: + if host.endswith("." + domain): + return True + return False + + +def _is_localhost( + client_ip: "Optional[str]" = None, + url_host: "Optional[str]" = None, + host_header: "Optional[str]" = None, + forwarded_host_header: "Optional[str]" = None, +) -> bool: + """ + Determine if a request originates from localhost. + + Based on the logic in relay's localhost filter. + """ + if client_ip is not None and client_ip in _LOCAL_IPS: + return True + + # URL host uses subdomain-aware matching + if url_host is not None and _host_matches_local_domain(url_host): + return True + + for header_val in (host_header, forwarded_host_header): + if header_val is not None: + domain = header_val.split(":")[0] + if _host_matches_local_domain(domain): + return True + + return False + + # This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support @contextmanager def nullcontext() -> "Iterator[None]": diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 6fbd0a8d7f..c0556aa37a 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -1333,3 +1333,54 @@ def test_error_event_no_user_ip_address_without_remote_addr( (event,) = events assert "ip_address" not in event.get("user", {}) + + +@pytest.mark.parametrize( + "client_kwargs, is_localhost", + [ + # Default werkzeug client: REMOTE_ADDR=127.0.0.1, Host=localhost + ({"path": "/dogs/"}, True), + # IPv6 loopback + ({"path": "/dogs/", "environ_base": {"REMOTE_ADDR": "::1"}}, True), + # Localhost Host header with non-local IP + ( + { + "path": "http://localhost:8000/dogs/", + "environ_overrides": {"REMOTE_ADDR": "1.2.3.4"}, + }, + True, + ), + # Non-local IP and non-local host + ( + { + "path": "/dogs/", + "base_url": "http://example.com", + "environ_overrides": {"REMOTE_ADDR": "1.2.3.4"}, + }, + False, + ), + ], +) +def test_is_localhost_span_attribute( + sentry_init, capture_items, client_kwargs, is_localhost +): + def dogpark(environ, start_response): + with sentry_sdk.traces.start_span(name="child-span"): + pass + start_response("200 OK", []) + return ["woof"] + + sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream") + app = SentryWsgiMiddleware(dogpark) + client = Client(app) + + items = capture_items("span") + + client.get(**client_kwargs) + + sentry_sdk.flush() + + child_span, server_span = [item.payload for item in items] + + assert server_span["attributes"]["sentry.is_localhost"] is is_localhost + assert child_span["attributes"]["sentry.is_localhost"] is is_localhost diff --git a/tests/test_utils.py b/tests/test_utils.py index 2be8698640..d3d70026e8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,6 +13,7 @@ Components, Dsn, _get_installed_modules, + _is_localhost, datetime_from_isoformat, ensure_integration_enabled, env_to_bool, @@ -1409,3 +1410,53 @@ def __init__(self): def test_package_version_is_none(): assert package_version("non_existent_package") is None + + +@pytest.mark.parametrize( + "kwargs, expected", + [ + # Client IP matches + ({"client_ip": "127.0.0.1"}, True), + ({"client_ip": "::1"}, True), + ({"client_ip": "192.168.1.1"}, False), + ({"client_ip": "10.0.0.1"}, False), + ({"client_ip": "8.8.8.8"}, False), + ({"client_ip": "::2"}, False), + # URL host (subdomain-aware matching) + ({"url_host": "localhost"}, True), + ({"url_host": "127.0.0.1"}, True), + ({"url_host": "foo.localhost"}, True), + ({"url_host": "bar.foo.localhost"}, True), + ({"url_host": "foolocalhost"}, False), + ({"url_host": "notlocalhost"}, False), + ({"url_host": "example.com"}, False), + # Host header (subdomain-aware matching after port strip) + ({"host_header": "localhost"}, True), + ({"host_header": "localhost:3000"}, True), + ({"host_header": "127.0.0.1:8080"}, True), + ({"host_header": "foo.localhost"}, True), + ({"host_header": "foo.localhost:8080"}, True), + ({"host_header": "example.com"}, False), + ({"host_header": "localhost.example.com"}, False), + # X-Forwarded-Host header + ({"forwarded_host_header": "localhost"}, True), + ({"forwarded_host_header": "localhost:3000"}, True), + ({"forwarded_host_header": "127.0.0.1:8080"}, True), + ({"forwarded_host_header": "foo.localhost"}, True), + ({"forwarded_host_header": "example.com"}, False), + # No args + ({}, False), + # All non-local + ( + { + "client_ip": "8.8.8.8", + "url_host": "example.com", + "host_header": "example.com", + "forwarded_host_header": "example.com", + }, + False, + ), + ], +) +def test_is_localhost(kwargs, expected): + assert _is_localhost(**kwargs) is expected From 4e6beb807731a30bc1dd7ae60799444b79d8bdd0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 11:39:56 +0200 Subject: [PATCH 2/6] feat(asgi): Add `sentry.is_localhost` for inbound filters (#7781) Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 2 of https://github.com/getsentry/sentry-python/issues/7727 --- sentry_sdk/integrations/_asgi_common.py | 7 ++- sentry_sdk/integrations/asgi.py | 23 ++++++-- tests/integrations/asgi/test_asgi.py | 73 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index dbf02decdc..d77d9a084f 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -88,7 +88,7 @@ def _get_query(asgi_scope: "Any") -> "Optional[str]": return urllib.parse.unquote(qs.decode("latin-1")) -def _get_ip(asgi_scope: "Any") -> str: +def _get_ip(asgi_scope: "Any") -> "Optional[str]": """ Extract IP Address from the ASGI scope based on request headers with fallback to scope client. """ @@ -103,7 +103,10 @@ def _get_ip(asgi_scope: "Any") -> str: except KeyError: pass - return asgi_scope.get("client")[0] + if asgi_scope.get("client"): + return asgi_scope["client"][0] + + return None def _get_request_data( diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 17ffbaacf7..073b1ee376 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -45,6 +45,7 @@ HAS_REAL_CONTEXTVARS, ContextVar, _get_installed_modules, + _is_localhost, capture_internal_exceptions, event_from_exception, has_data_collection_enabled, @@ -232,6 +233,19 @@ async def _run_app( processor = partial(self.event_processor, asgi_scope=scope) sentry_scope.add_event_processor(processor) + headers = _get_headers(scope) + sentry_scope.set_attribute( + SPANDATA.SENTRY_IS_LOCALHOST, + _is_localhost( + client_ip=_get_ip(scope), + url_host=scope.get("server")[0] + if scope.get("server") and not headers.get("host") + else None, + host_header=headers.get("host"), + forwarded_host_header=headers.get("x-forwarded-host"), + ), + ) + ty = scope["type"] ( transaction_name, @@ -254,17 +268,16 @@ async def _run_app( "network.protocol.name": ty, } - if scope.get("client"): + ip = _get_ip(scope) + if ip: client_options = sentry_sdk.get_client().options if has_data_collection_enabled(client_options): if client_options["data_collection"]["user_info"]: sentry_scope.set_attribute( - SPANDATA.USER_IP_ADDRESS, _get_ip(scope) + SPANDATA.USER_IP_ADDRESS, ip ) elif should_send_default_pii(): - sentry_scope.set_attribute( - SPANDATA.USER_IP_ADDRESS, _get_ip(scope) - ) + sentry_scope.set_attribute(SPANDATA.USER_IP_ADDRESS, ip) if ty in ("http", "websocket"): if ( diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 2de17ab15a..d4d47f7831 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -1490,3 +1490,76 @@ async def wrapped_app(scope, receive, send): else: assert "user.ip_address" not in server_span["attributes"] assert "user.ip_address" not in child_span["attributes"] + + +@pytest.mark.parametrize( + "client_ip, server, host_header, is_localhost", + [ + # Loopback IP + ("127.0.0.1", ("example.com", 80), b"example.com", True), + # IPv6 loopback + ("::1", ("example.com", 80), b"example.com", True), + # Localhost host header with non-local IP + ("203.0.113.50", ("example.com", 80), b"localhost:8000", True), + # Server bound to localhost, but host header is public (reverse proxy) + ("203.0.113.50", ("localhost", 8000), b"example.com", False), + # Server bound to localhost, no host header (fallback to server) + ("203.0.113.50", ("localhost", 8000), None, True), + # Non-local everything + ("203.0.113.50", ("example.com", 80), b"example.com", False), + ], +) +@pytest.mark.asyncio +async def test_is_localhost_attribute( + sentry_init, + capture_items, + client_ip, + server, + host_header, + is_localhost, +): + async def app(scope, receive, send): + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + with sentry_sdk.traces.start_span(name="child-span"): + pass + + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"content-type", b"text/plain"]], + } + ) + await send({"type": "http.response.body", "body": b"Hello, world!"}) + + sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream") + sentry_app = SentryAsgiMiddleware(app) + + async def wrapped_app(scope, receive, send): + if scope["type"] != "lifespan": + scope["client"] = (client_ip, 0) + scope["server"] = server + scope["headers"] = [(k, v) for k, v in scope["headers"] if k != b"host"] + if host_header is not None: + scope["headers"].append((b"host", host_header)) + + await sentry_app(scope, receive, send) + + async with TestClient(wrapped_app) as client: + items = capture_items("span") + await client.get("/some_url") + + sentry_sdk.flush() + + child_span, server_span = [item.payload for item in items] + + assert server_span["attributes"]["sentry.is_localhost"] is is_localhost + assert child_span["attributes"]["sentry.is_localhost"] is is_localhost From e1f5e704508c2d371e27c28efc32722c93b3321f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 12:09:10 +0200 Subject: [PATCH 3/6] feat(sanic): Add `sentry.is_localhost` for inbound filters (#7783) Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 3 of https://github.com/getsentry/sentry-python/issues/7727 --- sentry_sdk/integrations/sanic.py | 11 ++++++ sentry_sdk/utils.py | 2 +- tests/integrations/sanic/test_sanic.py | 54 ++++++++++++++++++++++++++ tests/test_utils.py | 3 ++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 2545f8f780..86534e49d3 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -21,6 +21,7 @@ from sentry_sdk.utils import ( CONTEXTVARS_ERROR_MESSAGE, HAS_REAL_CONTEXTVARS, + _is_localhost, capture_internal_exceptions, ensure_integration_enabled, event_from_exception, @@ -182,6 +183,16 @@ async def _context_enter(request: "Request") -> None: scope.clear_breadcrumbs() scope.add_event_processor(_make_request_processor(weak_request)) + scope.set_attribute( + SPANDATA.SENTRY_IS_LOCALHOST, + _is_localhost( + client_ip=request.remote_addr or None, + url_host=urlsplit(request.url).hostname, + host_header=request.headers.get("host"), + forwarded_host_header=request.headers.get("x-forwarded-host"), + ), + ) + if is_span_streaming_enabled: integration = client.get_integration(SanicIntegration) if ( diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 3889a7d15a..ff816208d8 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2229,7 +2229,7 @@ def _is_localhost( Based on the logic in relay's localhost filter. """ - if client_ip is not None and client_ip in _LOCAL_IPS: + if client_ip is not None and client_ip.strip("[]") in _LOCAL_IPS: return True # URL host uses subdomain-aware matching diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index 2d97a0ea12..1272ac8404 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -889,3 +889,57 @@ def test_remote_addr_data_collection( assert event["request"]["env"] == {"REMOTE_ADDR": ""} else: assert "env" not in event["request"] + + +@pytest.mark.skipif( + not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version" +) +@pytest.mark.parametrize( + "forwarded_for, host_header, is_localhost", + [ + # Loopback IP + ("127.0.0.1", "example.com", True), + # IPv6 loopback (quoted brackets per RFC 7239) + ('"[::1]"', "example.com", True), + # Localhost host header with non-local IP + ("203.0.113.50", "localhost:8000", True), + # Non-local everything + ("203.0.113.50", "example.com", False), + ], +) +def test_is_localhost_span_attribute( + sentry_init, app, capture_items, forwarded_for, host_header, is_localhost +): + app.config.FORWARDED_SECRET = "test" + + @app.route("/child-span") + def child_span_handler_localhost(request): + with sentry_sdk.traces.start_span(name="child-span"): + pass + return response.text("ok") + + sentry_init( + integrations=[SanicIntegration()], + default_integrations=False, + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + items = capture_items("span") + + c = get_client(app) + with c as client: + client.get( + "/child-span", + headers={ + "Forwarded": f"for={forwarded_for};secret=test", + "Host": host_header, + }, + ) + + sentry_sdk.flush() + + child_span, server_span = [item.payload for item in items] + + assert server_span["attributes"]["sentry.is_localhost"] is is_localhost + assert child_span["attributes"]["sentry.is_localhost"] is is_localhost diff --git a/tests/test_utils.py b/tests/test_utils.py index d3d70026e8..db0f5d6693 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1422,6 +1422,9 @@ def test_package_version_is_none(): ({"client_ip": "10.0.0.1"}, False), ({"client_ip": "8.8.8.8"}, False), ({"client_ip": "::2"}, False), + # Bracketed IPv6 (e.g. from Sanic's Forwarded header parsing) + ({"client_ip": "[::1]"}, True), + ({"client_ip": "[::2]"}, False), # URL host (subdomain-aware matching) ({"url_host": "localhost"}, True), ({"url_host": "127.0.0.1"}, True), From bd05d43d95259a345823f76ad857584f160834b6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 13:08:13 +0200 Subject: [PATCH 4/6] feat: Better IPv6 localhost detection (#7786) https://github.com/getsentry/sentry-python/pull/7783#discussion_r4143228323 --- sentry_sdk/utils.py | 12 ++++++++---- tests/test_utils.py | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index ff816208d8..a6420d84b2 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -101,7 +101,7 @@ """ _LOCAL_IPS = frozenset({"127.0.0.1", "::1"}) -_LOCAL_DOMAINS = frozenset({"localhost", "127.0.0.1"}) +_LOCAL_DOMAINS = _LOCAL_IPS.union({"localhost"}) def env_to_bool(value: "Any", *, strict: "Optional[bool]" = False) -> "bool | None": @@ -2236,9 +2236,13 @@ def _is_localhost( if url_host is not None and _host_matches_local_domain(url_host): return True - for header_val in (host_header, forwarded_host_header): - if header_val is not None: - domain = header_val.split(":")[0] + for header in (host_header, forwarded_host_header): + if header is not None: + if header.startswith("["): + # Bracketed IPv6, e.g. [::1]:8080 or [::1] + domain = header.split("]")[0].strip("[]") + else: + domain = header.split(":")[0] if _host_matches_local_domain(domain): return True diff --git a/tests/test_utils.py b/tests/test_utils.py index db0f5d6693..77def0eb6e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1428,6 +1428,7 @@ def test_package_version_is_none(): # URL host (subdomain-aware matching) ({"url_host": "localhost"}, True), ({"url_host": "127.0.0.1"}, True), + ({"url_host": "::1"}, True), ({"url_host": "foo.localhost"}, True), ({"url_host": "bar.foo.localhost"}, True), ({"url_host": "foolocalhost"}, False), @@ -1437,6 +1438,8 @@ def test_package_version_is_none(): ({"host_header": "localhost"}, True), ({"host_header": "localhost:3000"}, True), ({"host_header": "127.0.0.1:8080"}, True), + ({"host_header": "[::1]"}, True), + ({"host_header": "[::1]:8080"}, True), ({"host_header": "foo.localhost"}, True), ({"host_header": "foo.localhost:8080"}, True), ({"host_header": "example.com"}, False), @@ -1445,6 +1448,7 @@ def test_package_version_is_none(): ({"forwarded_host_header": "localhost"}, True), ({"forwarded_host_header": "localhost:3000"}, True), ({"forwarded_host_header": "127.0.0.1:8080"}, True), + ({"forwarded_host_header": "[::1]:8080"}, True), ({"forwarded_host_header": "foo.localhost"}, True), ({"forwarded_host_header": "example.com"}, False), # No args From 0e9f701685ceb5eef8ee464c9a9160e6f2a140b1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 13:08:43 +0200 Subject: [PATCH 5/6] feat(aiohttp): Add `sentry.is_localhost` for inbound filters (#7785) Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 4 of https://github.com/getsentry/sentry-python/issues/7727 --- sentry_sdk/integrations/aiohttp.py | 23 +++++++++ tests/integrations/aiohttp/test_aiohttp.py | 56 ++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 4f7b421357..1ae4d47eb2 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -46,6 +46,7 @@ AnnotatedValue, _get_aws_sigv4_signed_headers_from_authorization_header, _get_aws_sigv4_signed_headers_from_url_query_string, + _is_localhost, _register_control_flow_exception, capture_internal_exceptions, ensure_integration_enabled, @@ -146,6 +147,17 @@ async def sentry_app_handle( scope.clear_breadcrumbs() scope.add_event_processor(_make_request_processor(weak_request)) + scope.set_attribute( + SPANDATA.SENTRY_IS_LOCALHOST, + _is_localhost( + client_ip=_get_client_ip(request), + host_header=request.headers.get("Host"), + forwarded_host_header=request.headers.get( + "X-Forwarded-Host" + ), + ), + ) + headers = dict(request.headers) span_ctx: "ContextManager[Union[Span, StreamedSpan]]" @@ -643,6 +655,17 @@ def aiohttp_processor( return aiohttp_processor +def _get_client_ip(request: "Request") -> "Optional[str]": + x_forwarded_for = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + if x_forwarded_for: + return x_forwarded_for + + if request.headers.get("X-Real-IP"): + return request.headers["X-Real-IP"] + + return request.remote + + def _capture_exception() -> "ExcInfo": exc_info = sys.exc_info() event, hint = event_from_exception( diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 1c3166de64..b4301ad628 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -2416,6 +2416,62 @@ async def hello(request): assert event["request"]["query_string"] == expected_query +@pytest.mark.asyncio +@pytest.mark.parametrize( + "forwarded_for, host_header, is_localhost", + [ + pytest.param(None, None, True, id="loopback-ip"), + pytest.param( + "93.184.216.34", "localhost:8080", True, id="localhost-host-header" + ), + pytest.param("93.184.216.34", "example.com", False, id="non-local"), + ], +) +async def test_is_localhost_attribute( + sentry_init, + aiohttp_client, + capture_items, + forwarded_for, + host_header, + is_localhost, +): + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + async def hello(request): + with sentry_sdk.traces.start_span(name="child"): + pass + return web.Response(text="hello") + + app = web.Application() + app.router.add_get("/", hello) + + items = capture_items("span") + + client = await aiohttp_client(app) + headers = {} + if host_header: + headers["Host"] = host_header + if forwarded_for: + headers["X-Forwarded-For"] = forwarded_for + resp = await client.get("/", headers=headers) + assert resp.status == 200 + + sentry_sdk.flush() + + child_spans = [item.payload for item in items if not item.payload.get("is_segment")] + server_spans = [item.payload for item in items if item.payload.get("is_segment")] + + assert len(server_spans) == 1 + assert len(child_spans) == 1 + + assert server_spans[0]["attributes"][SPANDATA.SENTRY_IS_LOCALHOST] == is_localhost + assert child_spans[0]["attributes"][SPANDATA.SENTRY_IS_LOCALHOST] == is_localhost + + @pytest.mark.asyncio @pytest.mark.parametrize( "init_kwargs, expect_remote_addr", DATA_COLLECTION_REMOTE_ADDR_CASES From e0ab7924844089df9ddf4f8034491f132ccbfe75 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 13:23:42 +0200 Subject: [PATCH 6/6] feat(tornado): Add `sentry.is_localhost` for inbound filters (#7787) Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 5 of https://github.com/getsentry/sentry-python/issues/7727 --- sentry_sdk/integrations/tornado.py | 21 ++++++++++ tests/integrations/tornado/test_tornado.py | 47 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index d0be7fceb0..2080a60779 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -22,6 +22,7 @@ CONTEXTVARS_ERROR_MESSAGE, HAS_REAL_CONTEXTVARS, AnnotatedValue, + _is_localhost, capture_internal_exceptions, ensure_integration_enabled, event_from_exception, @@ -127,6 +128,15 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] processor = _make_event_processor(weak_handler) scope.add_event_processor(processor) + scope.set_attribute( + SPANDATA.SENTRY_IS_LOCALHOST, + _is_localhost( + client_ip=_get_client_ip(self.request), + host_header=self.request.headers.get("Host"), + forwarded_host_header=self.request.headers.get("X-Forwarded-Host"), + ), + ) + span_ctx: "ContextManager[Union[Span, StreamedSpan, None]]" if is_span_streaming_enabled: @@ -198,6 +208,17 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] span.status = "error" if status_int >= 400 else "ok" +def _get_client_ip(request: "Any") -> "Optional[str]": + x_forwarded_for = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + if x_forwarded_for: + return x_forwarded_for + + if request.headers.get("X-Real-IP"): + return request.headers["X-Real-IP"] + + return request.remote_ip + + def _get_request_attributes(request: "Any") -> "Dict[str, Any]": attributes = {} # type: Dict[str, Any] client_options = sentry_sdk.get_client().options diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index e995e3e2be..561b1b22bb 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -7,6 +7,7 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.tornado import TornadoIntegration from tests.integrations.utils import ( DATA_COLLECTION_REMOTE_ADDR_CASES, @@ -1118,6 +1119,52 @@ def test_client_address_span_attribute_data_collection( assert "client.address" not in server_span["attributes"] +@pytest.mark.parametrize( + "forwarded_for, host_header, is_localhost", + [ + pytest.param(None, None, True, id="loopback-ip"), + pytest.param( + "93.184.216.34", "localhost:8080", True, id="localhost-host-header" + ), + pytest.param("93.184.216.34", "example.com", False, id="non-local"), + ], +) +def test_is_localhost_span_attribute( + tornado_testcase, + sentry_init, + capture_items, + forwarded_for, + host_header, + is_localhost, +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + items = capture_items("span") + + client = tornado_testcase(Application([(r"/hi", ChildSpanHandler)])) + headers = {} + if forwarded_for: + headers["X-Forwarded-For"] = forwarded_for + if host_header: + headers["Host"] = host_header + client.fetch("/hi", headers=headers if headers else None) + + sentry_sdk.flush() + + child_spans = [item.payload for item in items if not item.payload.get("is_segment")] + server_spans = [item.payload for item in items if item.payload.get("is_segment")] + + assert len(server_spans) == 1 + assert len(child_spans) == 1 + + assert server_spans[0]["attributes"][SPANDATA.SENTRY_IS_LOCALHOST] == is_localhost + assert child_spans[0]["attributes"][SPANDATA.SENTRY_IS_LOCALHOST] == is_localhost + + @pytest.mark.parametrize( "init_kwargs, expect_remote_addr", DATA_COLLECTION_REMOTE_ADDR_CASES )