Skip to content

Commit abb80cf

Browse files
committed
feat(wsgi): Add sentry.is_localhost for inbound filters
1 parent 2a499ec commit abb80cf

5 files changed

Lines changed: 159 additions & 0 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,14 @@ class SPANDATA:
11641164
Example: "prod"
11651165
"""
11661166

1167+
SENTRY_IS_LOCALHOST = "sentry.is_localhost"
1168+
"""
1169+
Indicates whether a telemetry item was sent on a host, device or browser on
1170+
a localhost URL or IP address.
1171+
1172+
Used in inbound filters.
1173+
"""
1174+
11671175
SENTRY_RELEASE = "sentry.release"
11681176
"""
11691177
The Sentry release.

‎sentry_sdk/integrations/wsgi.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
capture_internal_exceptions,
2222
event_from_exception,
2323
has_data_collection_enabled,
24+
is_localhost,
2425
nullcontext,
2526
reraise,
2627
)
@@ -126,6 +127,16 @@ def __call__(
126127
)
127128
)
128129

130+
scope.set_attribute(
131+
SPANDATA.SENTRY_IS_LOCALHOST,
132+
is_localhost(
133+
client_ip=get_client_ip(environ),
134+
url_host=environ.get("SERVER_NAME"),
135+
host_header=environ.get("HTTP_HOST"),
136+
forwarded_host_header=environ.get("HTTP_X_FORWARDED_HOST"),
137+
),
138+
)
139+
129140
method = environ.get("REQUEST_METHOD", "").upper()
130141

131142
span_ctx: "Optional[ContextManager[Union[Span, StreamedSpan, None]]]" = None

‎sentry_sdk/utils.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@
100100
be affected by this limit if they have a custom recursion limit.
101101
"""
102102

103+
_LOCAL_IPS = frozenset({"127.0.0.1", "::1"})
104+
_LOCAL_DOMAINS = frozenset({"localhost", "127.0.0.1"})
105+
103106

104107
def env_to_bool(value: "Any", *, strict: "Optional[bool]" = False) -> "bool | None":
105108
"""Casts an ENV variable value to boolean using the constants defined above.
@@ -2205,6 +2208,44 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
22052208
return {"value": safe_repr(val), "type": "string"}
22062209

22072210

2211+
def _host_matches_local_domain(host: str) -> bool:
2212+
"""Check if host matches a local domain, including subdomains like foo.localhost."""
2213+
if host in _LOCAL_DOMAINS:
2214+
return True
2215+
for domain in _LOCAL_DOMAINS:
2216+
if host.endswith("." + domain):
2217+
return True
2218+
return False
2219+
2220+
2221+
def is_localhost(
2222+
client_ip: "Optional[str]" = None,
2223+
url_host: "Optional[str]" = None,
2224+
host_header: "Optional[str]" = None,
2225+
forwarded_host_header: "Optional[str]" = None,
2226+
) -> bool:
2227+
"""
2228+
Determine if a request originates from localhost.
2229+
2230+
Based on the logic in relay's localhost filter.
2231+
"""
2232+
if client_ip is not None and client_ip in _LOCAL_IPS:
2233+
return True
2234+
2235+
# URL host uses subdomain-aware matching
2236+
if url_host is not None and _host_matches_local_domain(url_host):
2237+
return True
2238+
2239+
# Host / X-Forwarded-Host headers: exact match after stripping port
2240+
for header_val in (host_header, forwarded_host_header):
2241+
if header_val is not None:
2242+
domain = header_val.split(":")[0]
2243+
if domain in _LOCAL_DOMAINS:
2244+
return True
2245+
2246+
return False
2247+
2248+
22082249
# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support
22092250
@contextmanager
22102251
def nullcontext() -> "Iterator[None]":

‎tests/integrations/wsgi/test_wsgi.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,3 +1333,54 @@ def test_error_event_no_user_ip_address_without_remote_addr(
13331333
(event,) = events
13341334

13351335
assert "ip_address" not in event.get("user", {})
1336+
1337+
1338+
@pytest.mark.parametrize(
1339+
"client_kwargs, is_localhost",
1340+
[
1341+
# Default werkzeug client: REMOTE_ADDR=127.0.0.1, Host=localhost
1342+
({"path": "/dogs/"}, True),
1343+
# IPv6 loopback
1344+
({"path": "/dogs/", "environ_base": {"REMOTE_ADDR": "::1"}}, True),
1345+
# Localhost Host header with non-local IP
1346+
(
1347+
{
1348+
"path": "http://localhost:8000/dogs/",
1349+
"environ_overrides": {"REMOTE_ADDR": "1.2.3.4"},
1350+
},
1351+
True,
1352+
),
1353+
# Non-local IP and non-local host
1354+
(
1355+
{
1356+
"path": "/dogs/",
1357+
"base_url": "http://example.com",
1358+
"environ_overrides": {"REMOTE_ADDR": "1.2.3.4"},
1359+
},
1360+
False,
1361+
),
1362+
],
1363+
)
1364+
def test_is_localhost_span_attribute(
1365+
sentry_init, capture_items, client_kwargs, is_localhost
1366+
):
1367+
def dogpark(environ, start_response):
1368+
with sentry_sdk.traces.start_span(name="child-span"):
1369+
pass
1370+
start_response("200 OK", [])
1371+
return ["woof"]
1372+
1373+
sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream")
1374+
app = SentryWsgiMiddleware(dogpark)
1375+
client = Client(app)
1376+
1377+
items = capture_items("span")
1378+
1379+
client.get(**client_kwargs)
1380+
1381+
sentry_sdk.flush()
1382+
1383+
child_span, server_span = [item.payload for item in items]
1384+
1385+
assert server_span["attributes"]["sentry.is_localhost"] is is_localhost
1386+
assert child_span["attributes"]["sentry.is_localhost"] is is_localhost

‎tests/test_utils.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
get_error_message,
2424
get_git_revision,
2525
get_lines_from_file,
26+
is_localhost,
2627
is_sentry_url,
2728
is_valid_sample_rate,
2829
logger,
@@ -1409,3 +1410,50 @@ def __init__(self):
14091410

14101411
def test_package_version_is_none():
14111412
assert package_version("non_existent_package") is None
1413+
1414+
1415+
@pytest.mark.parametrize(
1416+
"kwargs, expected",
1417+
[
1418+
# Client IP matches
1419+
({"client_ip": "127.0.0.1"}, True),
1420+
({"client_ip": "::1"}, True),
1421+
({"client_ip": "192.168.1.1"}, False),
1422+
({"client_ip": "10.0.0.1"}, False),
1423+
({"client_ip": "8.8.8.8"}, False),
1424+
({"client_ip": "::2"}, False),
1425+
# URL host (subdomain-aware matching)
1426+
({"url_host": "localhost"}, True),
1427+
({"url_host": "127.0.0.1"}, True),
1428+
({"url_host": "foo.localhost"}, True),
1429+
({"url_host": "bar.foo.localhost"}, True),
1430+
({"url_host": "foolocalhost"}, False),
1431+
({"url_host": "notlocalhost"}, False),
1432+
({"url_host": "example.com"}, False),
1433+
# Host header (exact match after port strip, no subdomain matching)
1434+
({"host_header": "localhost"}, True),
1435+
({"host_header": "localhost:3000"}, True),
1436+
({"host_header": "127.0.0.1:8080"}, True),
1437+
({"host_header": "example.com"}, False),
1438+
({"host_header": "localhost.example.com"}, False),
1439+
# X-Forwarded-Host header
1440+
({"forwarded_host_header": "localhost"}, True),
1441+
({"forwarded_host_header": "localhost:3000"}, True),
1442+
({"forwarded_host_header": "127.0.0.1:8080"}, True),
1443+
({"forwarded_host_header": "example.com"}, False),
1444+
# No args
1445+
({}, False),
1446+
# All non-local
1447+
(
1448+
{
1449+
"client_ip": "8.8.8.8",
1450+
"url_host": "example.com",
1451+
"host_header": "example.com",
1452+
"forwarded_host_header": "example.com",
1453+
},
1454+
False,
1455+
),
1456+
],
1457+
)
1458+
def test_is_localhost(kwargs, expected):
1459+
assert is_localhost(**kwargs) is expected

0 commit comments

Comments
 (0)