Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions sentry_sdk/integrations/_asgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions sentry_sdk/integrations/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 18 additions & 7 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from sentry_sdk.utils import (
_get_installed_modules,
_is_localhost,
capture_internal_exceptions,
event_from_exception,
has_data_collection_enabled,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions sentry_sdk/integrations/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions sentry_sdk/integrations/tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions sentry_sdk/integrations/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
44 changes: 44 additions & 0 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
55 changes: 55 additions & 0 deletions tests/integrations/aiohttp/test_aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading