diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5e61845f15..0253d5689b 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1139,6 +1139,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/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 640412657d..f1c660b6fb 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/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index c743b40710..81417de689 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -38,6 +38,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, @@ -127,6 +128,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) sentry_sdk.continue_trace(headers) @@ -493,6 +505,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/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 3214cf4584..ed985b138a 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -35,6 +35,7 @@ ) from sentry_sdk.utils import ( _get_installed_modules, + _is_localhost, capture_internal_exceptions, event_from_exception, has_data_collection_enabled, @@ -201,6 +202,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, @@ -221,17 +235,14 @@ 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) - ) + sentry_scope.set_attribute(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 ty == "websocket" or method in self.http_methods_to_capture: diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index caa620ddd6..4ec8d670aa 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -13,6 +13,7 @@ from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers from sentry_sdk.traces import SegmentNameSource from sentry_sdk.utils import ( + _is_localhost, capture_internal_exceptions, ensure_integration_enabled, event_from_exception, @@ -133,6 +134,16 @@ async def _context_enter(request: "Request") -> None: sentry_sdk.continue_trace(dict(request.headers)) scope.set_custom_sampling_context({"sanic_request": 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 request.remote_addr and client.options["data_collection"]["user_info"]: scope.set_attribute(SPANDATA.USER_IP_ADDRESS, request.remote_addr) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index cacee99caa..3a8cebd87f 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -15,6 +15,7 @@ from sentry_sdk.traces import SegmentNameSource from sentry_sdk.utils import ( AnnotatedValue, + _is_localhost, capture_internal_exceptions, ensure_integration_enabled, event_from_exception, @@ -96,6 +97,15 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] sentry_sdk.continue_trace(dict(headers)) scope.set_custom_sampling_context({"tornado_request": self.request}) + 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"), + ), + ) + if self.request.remote_ip and client.options["data_collection"]["user_info"]: scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) @@ -132,6 +142,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/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index c589e802d1..a152fabc55 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -16,6 +16,7 @@ from sentry_sdk.sessions import track_session from sentry_sdk.traces import SegmentNameSource from sentry_sdk.utils import ( + _is_localhost, capture_internal_exceptions, event_from_exception, has_data_collection_enabled, @@ -125,6 +126,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: "ContextManager[Union[Span, None]]" = nullcontext() diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 7c584f8cde..37b12f36dc 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -101,6 +101,9 @@ be affected by this limit if they have a custom recursion limit. """ +_LOCAL_IPS = frozenset({"127.0.0.1", "::1"}) +_LOCAL_DOMAINS = _LOCAL_IPS.union({"localhost"}) + def env_to_bool(value: "Any", *, strict: "Optional[bool]" = False) -> "bool | None": """Casts an ENV variable value to boolean using the constants defined above. @@ -2035,3 +2038,44 @@ def deprecation_warning(msg: str) -> None: For other types of warnings, use logger.warning(). """ warnings.warn(msg, stacklevel=3, category=DeprecationWarning) + + +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.strip("[]") 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 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 + + return False diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 4ab52d30ea..e4f72991db 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1962,3 +1962,58 @@ async def hello(request): assert event["request"]["env"] == {"REMOTE_ADDR": "127.0.0.1"} else: assert "env" not in event["request"] + + +@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, + ) + + 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 diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index b8e1e20d5c..c0e974993c 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -1229,3 +1229,77 @@ 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) + + 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 diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index e25dce66d0..28cb52c418 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -772,3 +772,53 @@ def test_remote_addr_data_collection( assert event["request"]["env"] == {"REMOTE_ADDR": ""} else: assert "env" not in event["request"] + + +@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, + ) + + 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/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index d87e8e4a96..35431c0d8e 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 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, @@ -859,6 +860,51 @@ 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, + ) + + 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( "data_collection, expect_remote_addr", DATA_COLLECTION_REMOTE_ADDR_CASES ) diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 3546aeeb9c..c3eb0e1da0 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -1054,3 +1054,55 @@ 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) + + 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 d9fa6a0276..7f740f299e 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, @@ -1395,3 +1396,60 @@ 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), + # 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), + ({"url_host": "::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": "[::1]"}, True), + ({"host_header": "[::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": "[::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