Skip to content

Commit 4e6beb8

Browse files
authored
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 #7727
1 parent f0438d3 commit 4e6beb8

3 files changed

Lines changed: 96 additions & 7 deletions

File tree

‎sentry_sdk/integrations/_asgi_common.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def _get_query(asgi_scope: "Any") -> "Optional[str]":
8888
return urllib.parse.unquote(qs.decode("latin-1"))
8989

9090

91-
def _get_ip(asgi_scope: "Any") -> str:
91+
def _get_ip(asgi_scope: "Any") -> "Optional[str]":
9292
"""
9393
Extract IP Address from the ASGI scope based on request headers with fallback to scope client.
9494
"""
@@ -103,7 +103,10 @@ def _get_ip(asgi_scope: "Any") -> str:
103103
except KeyError:
104104
pass
105105

106-
return asgi_scope.get("client")[0]
106+
if asgi_scope.get("client"):
107+
return asgi_scope["client"][0]
108+
109+
return None
107110

108111

109112
def _get_request_data(

‎sentry_sdk/integrations/asgi.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
HAS_REAL_CONTEXTVARS,
4646
ContextVar,
4747
_get_installed_modules,
48+
_is_localhost,
4849
capture_internal_exceptions,
4950
event_from_exception,
5051
has_data_collection_enabled,
@@ -232,6 +233,19 @@ async def _run_app(
232233
processor = partial(self.event_processor, asgi_scope=scope)
233234
sentry_scope.add_event_processor(processor)
234235

236+
headers = _get_headers(scope)
237+
sentry_scope.set_attribute(
238+
SPANDATA.SENTRY_IS_LOCALHOST,
239+
_is_localhost(
240+
client_ip=_get_ip(scope),
241+
url_host=scope.get("server")[0]
242+
if scope.get("server") and not headers.get("host")
243+
else None,
244+
host_header=headers.get("host"),
245+
forwarded_host_header=headers.get("x-forwarded-host"),
246+
),
247+
)
248+
235249
ty = scope["type"]
236250
(
237251
transaction_name,
@@ -254,17 +268,16 @@ async def _run_app(
254268
"network.protocol.name": ty,
255269
}
256270

257-
if scope.get("client"):
271+
ip = _get_ip(scope)
272+
if ip:
258273
client_options = sentry_sdk.get_client().options
259274
if has_data_collection_enabled(client_options):
260275
if client_options["data_collection"]["user_info"]:
261276
sentry_scope.set_attribute(
262-
SPANDATA.USER_IP_ADDRESS, _get_ip(scope)
277+
SPANDATA.USER_IP_ADDRESS, ip
263278
)
264279
elif should_send_default_pii():
265-
sentry_scope.set_attribute(
266-
SPANDATA.USER_IP_ADDRESS, _get_ip(scope)
267-
)
280+
sentry_scope.set_attribute(SPANDATA.USER_IP_ADDRESS, ip)
268281

269282
if ty in ("http", "websocket"):
270283
if (

‎tests/integrations/asgi/test_asgi.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,3 +1490,76 @@ async def wrapped_app(scope, receive, send):
14901490
else:
14911491
assert "user.ip_address" not in server_span["attributes"]
14921492
assert "user.ip_address" not in child_span["attributes"]
1493+
1494+
1495+
@pytest.mark.parametrize(
1496+
"client_ip, server, host_header, is_localhost",
1497+
[
1498+
# Loopback IP
1499+
("127.0.0.1", ("example.com", 80), b"example.com", True),
1500+
# IPv6 loopback
1501+
("::1", ("example.com", 80), b"example.com", True),
1502+
# Localhost host header with non-local IP
1503+
("203.0.113.50", ("example.com", 80), b"localhost:8000", True),
1504+
# Server bound to localhost, but host header is public (reverse proxy)
1505+
("203.0.113.50", ("localhost", 8000), b"example.com", False),
1506+
# Server bound to localhost, no host header (fallback to server)
1507+
("203.0.113.50", ("localhost", 8000), None, True),
1508+
# Non-local everything
1509+
("203.0.113.50", ("example.com", 80), b"example.com", False),
1510+
],
1511+
)
1512+
@pytest.mark.asyncio
1513+
async def test_is_localhost_attribute(
1514+
sentry_init,
1515+
capture_items,
1516+
client_ip,
1517+
server,
1518+
host_header,
1519+
is_localhost,
1520+
):
1521+
async def app(scope, receive, send):
1522+
if scope["type"] == "lifespan":
1523+
while True:
1524+
message = await receive()
1525+
if message["type"] == "lifespan.startup":
1526+
await send({"type": "lifespan.startup.complete"})
1527+
elif message["type"] == "lifespan.shutdown":
1528+
await send({"type": "lifespan.shutdown.complete"})
1529+
return
1530+
1531+
with sentry_sdk.traces.start_span(name="child-span"):
1532+
pass
1533+
1534+
await send(
1535+
{
1536+
"type": "http.response.start",
1537+
"status": 200,
1538+
"headers": [[b"content-type", b"text/plain"]],
1539+
}
1540+
)
1541+
await send({"type": "http.response.body", "body": b"Hello, world!"})
1542+
1543+
sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream")
1544+
sentry_app = SentryAsgiMiddleware(app)
1545+
1546+
async def wrapped_app(scope, receive, send):
1547+
if scope["type"] != "lifespan":
1548+
scope["client"] = (client_ip, 0)
1549+
scope["server"] = server
1550+
scope["headers"] = [(k, v) for k, v in scope["headers"] if k != b"host"]
1551+
if host_header is not None:
1552+
scope["headers"].append((b"host", host_header))
1553+
1554+
await sentry_app(scope, receive, send)
1555+
1556+
async with TestClient(wrapped_app) as client:
1557+
items = capture_items("span")
1558+
await client.get("/some_url")
1559+
1560+
sentry_sdk.flush()
1561+
1562+
child_span, server_span = [item.payload for item in items]
1563+
1564+
assert server_span["attributes"]["sentry.is_localhost"] is is_localhost
1565+
assert child_span["attributes"]["sentry.is_localhost"] is is_localhost

0 commit comments

Comments
 (0)