feat(wsgi): Add `sentry.is_localhost` for inbound filters (#7780) · intertermux-code/sentry-python@f0438d3 · GitHub
Skip to content

Commit f0438d3

Browse files
authored
feat(wsgi): Add sentry.is_localhost for inbound filters (getsentry#7780)
Mirrors https://github.com/getsentry/relay/blob/master/relay-filter/src/localhost.rs Part 1 of getsentry#7727
1 parent 2a499ec commit f0438d3

5 files changed

Lines changed: 163 additions & 0 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 8 additions & 0 deletions

‎sentry_sdk/integrations/wsgi.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1919
from sentry_sdk.utils import (
2020
ContextVar,
21+
_is_localhost,
2122
capture_internal_exceptions,
2223
event_from_exception,
2324
has_data_collection_enabled,
@@ -126,6 +127,18 @@ 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+
if not environ.get("HTTP_HOST")
136+
else None,
137+
host_header=environ.get("HTTP_HOST"),
138+
forwarded_host_header=environ.get("HTTP_X_FORWARDED_HOST"),
139+
),
140+
)
141+
129142
method = environ.get("REQUEST_METHOD", "").upper()
130143

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

‎sentry_sdk/utils.py‎

Lines changed: 40 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,43 @@ 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+
for header_val in (host_header, forwarded_host_header):
2240+
if header_val is not None:
2241+
domain = header_val.split(":")[0]
2242+
if _host_matches_local_domain(domain):
2243+
return True
2244+
2245+
return False
2246+
2247+
22082248
# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support
22092249
@contextmanager
22102250
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: 51 additions & 0 deletions

0 commit comments

Comments
 (0)