From 9426a24a29af3459b6245f2cf5d202ffc31c6267 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 09:47:48 +0200 Subject: [PATCH 1/2] feat(wsgi): Add sentry.is_localhost for inbound filters --- sentry_sdk/consts.py | 8 +++++ sentry_sdk/integrations/wsgi.py | 13 +++++++ sentry_sdk/utils.py | 41 ++++++++++++++++++++++ tests/integrations/wsgi/test_wsgi.py | 51 ++++++++++++++++++++++++++++ tests/test_utils.py | 48 ++++++++++++++++++++++++++ 5 files changed, 161 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..75b37e5feb 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -21,6 +21,7 @@ capture_internal_exceptions, event_from_exception, has_data_collection_enabled, + is_localhost, nullcontext, reraise, ) @@ -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..33ebf6f3ac 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,44 @@ 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 + + # Host / X-Forwarded-Host headers: exact match after stripping port + for header_val in (host_header, forwarded_host_header): + if header_val is not None: + domain = header_val.split(":")[0] + if domain in _LOCAL_DOMAINS: + 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..ee4474d9f6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -23,6 +23,7 @@ get_error_message, get_git_revision, get_lines_from_file, + is_localhost, is_sentry_url, is_valid_sample_rate, logger, @@ -1409,3 +1410,50 @@ 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 (exact match after port strip, no subdomain matching) + ({"host_header": "localhost"}, True), + ({"host_header": "localhost:3000"}, True), + ({"host_header": "127.0.0.1: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": "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 c734878ba6173ea41adb009a94531f22f2dfb37b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 30 Sep 2026 10:48:00 +0200 Subject: [PATCH 2/2] fixes --- sentry_sdk/integrations/wsgi.py | 4 ++-- sentry_sdk/utils.py | 5 ++--- tests/test_utils.py | 9 ++++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 75b37e5feb..ab04308f6e 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -18,10 +18,10 @@ 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, - is_localhost, nullcontext, reraise, ) @@ -129,7 +129,7 @@ def __call__( scope.set_attribute( SPANDATA.SENTRY_IS_LOCALHOST, - is_localhost( + _is_localhost( client_ip=get_client_ip(environ), url_host=environ.get("SERVER_NAME") if not environ.get("HTTP_HOST") diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 33ebf6f3ac..3889a7d15a 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2218,7 +2218,7 @@ def _host_matches_local_domain(host: str) -> bool: return False -def is_localhost( +def _is_localhost( client_ip: "Optional[str]" = None, url_host: "Optional[str]" = None, host_header: "Optional[str]" = None, @@ -2236,11 +2236,10 @@ def is_localhost( if url_host is not None and _host_matches_local_domain(url_host): return True - # Host / X-Forwarded-Host headers: exact match after stripping port for header_val in (host_header, forwarded_host_header): if header_val is not None: domain = header_val.split(":")[0] - if domain in _LOCAL_DOMAINS: + if _host_matches_local_domain(domain): return True return False diff --git a/tests/test_utils.py b/tests/test_utils.py index ee4474d9f6..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, @@ -23,7 +24,6 @@ get_error_message, get_git_revision, get_lines_from_file, - is_localhost, is_sentry_url, is_valid_sample_rate, logger, @@ -1430,16 +1430,19 @@ def test_package_version_is_none(): ({"url_host": "foolocalhost"}, False), ({"url_host": "notlocalhost"}, False), ({"url_host": "example.com"}, False), - # Host header (exact match after port strip, no subdomain matching) + # 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), @@ -1456,4 +1459,4 @@ def test_package_version_is_none(): ], ) def test_is_localhost(kwargs, expected): - assert is_localhost(**kwargs) is expected + assert _is_localhost(**kwargs) is expected