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 @@ -1164,6 +1164,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
13 changes: 13 additions & 0 deletions sentry_sdk/integrations/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sentry_sdk.tracing_utils import has_span_streaming_enabled
from sentry_sdk.utils import (
ContextVar,
_is_localhost,

Check warning on line 21 in sentry_sdk/integrations/wsgi.py

View check run for this annotation

@sentry/warden / warden: code-review

sentry.is_localhost trusts forwarding headers regardless of proxy configuration

The WSGI middleware uses `get_client_ip(environ)` and `HTTP_X_FORWARDED_HOST` to determine `sentry.is_localhost` without gating either on `use_x_forwarded_for`. A client able to supply these headers can make the attribute true even when forwarded-host support is disabled, potentially causing localhost inbound filters to discard the event. Avoid using untrusted forwarding headers for this determination, or use them only when explicitly trusted.
Comment thread
sentrivana marked this conversation as resolved.
Resolved
capture_internal_exceptions,
event_from_exception,
has_data_collection_enabled,
Expand Down Expand Up @@ -126,6 +127,18 @@
)
)

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,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
host_header=environ.get("HTTP_HOST"),
forwarded_host_header=environ.get("HTTP_X_FORWARDED_HOST"),
Comment thread
sentrivana marked this conversation as resolved.
Resolved
),
)

Check warning on line 140 in sentry_sdk/integrations/wsgi.py

View check run for this annotation

@sentry/warden / warden: code-review

[QUR-VQR] sentry.is_localhost trusts forwarding headers regardless of proxy configuration (additional location)

The WSGI middleware uses `get_client_ip(environ)` and `HTTP_X_FORWARDED_HOST` to determine `sentry.is_localhost` without gating either on `use_x_forwarded_for`. A client able to supply these headers can make the attribute true even when forwarded-host support is disabled, potentially causing localhost inbound filters to discard the event. Avoid using untrusted forwarding headers for this determination, or use them only when explicitly trusted.

method = environ.get("REQUEST_METHOD", "").upper()

span_ctx: "Optional[ContextManager[Union[Span, StreamedSpan, None]]]" = None
Expand Down
40 changes: 40 additions & 0 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
be affected by this limit if they have a custom recursion limit.
"""

_LOCAL_IPS = frozenset({"127.0.0.1", "::1"})
Comment thread
sentrivana marked this conversation as resolved.
Resolved
_LOCAL_DOMAINS = frozenset({"localhost", "127.0.0.1"})

Check warning on line 104 in sentry_sdk/utils.py

View check run for this annotation

@sentry/warden / warden: find-bugs

sentry.is_localhost trusts spoofable forwarding headers

In `wsgi.py`, `_is_localhost()` always uses `get_client_ip()` (X-Forwarded-For) and `HTTP_X_FORWARDED_HOST` without gating on `use_x_forwarded_for`, so clients can force `sentry.is_localhost=True` and get events dropped by localhost inbound filters.

Check warning on line 104 in sentry_sdk/utils.py

View check run for this annotation

@sentry/warden / warden: find-bugs

IPv6 loopback Host headers are not detected as localhost

`_LOCAL_DOMAINS` omits `::1`, and host parsing uses `split(":")[0]`, so Host values like `[::1]` or `[::1]:8080` never match unless `client_ip` is already loopback.
Comment thread
sentrivana marked this conversation as resolved.
Resolved
Comment thread
sentrivana marked this conversation as resolved.
Resolved
Comment thread
sentrivana marked this conversation as resolved.
Resolved
Comment on lines +103 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv6 loopback Host headers are not detected as localhost

_LOCAL_DOMAINS omits ::1, and host parsing uses split(":")[0], so Host values like [::1] or [::1]:8080 never match unless client_ip is already loopback.

Evidence
  • _LOCAL_IPS includes ::1, but _LOCAL_DOMAINS is only localhost and 127.0.0.1.
  • _is_localhost() strips ports with header_val.split(":")[0] before _host_matches_local_domain().
  • For [::1]:8080, that yields [, which does not match; bare ::1 is also not in _LOCAL_DOMAINS.
  • Tests cover client_ip="::1" but not IPv6 Host / X-Forwarded-Host forms.
Also found at 3 additional locations
  • tests/integrations/wsgi/test_wsgi.py:1344-1345
  • sentry_sdk/utils.py:2241-2243
  • tests/test_utils.py:1433-1439

Identified by Warden · find-bugs · HAM-7BK



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 @@ -2205,6 +2208,43 @@
return {"value": safe_repr(val), "type": "string"}


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 in _LOCAL_IPS:
return True
Comment thread
sentrivana marked this conversation as resolved.
Resolved

# URL host uses subdomain-aware matching
if url_host is not None and _host_matches_local_domain(url_host):
return True

for header_val in (host_header, forwarded_host_header):
if header_val is not None:
domain = header_val.split(":")[0]
if _host_matches_local_domain(domain):
return True

Check warning on line 2243 in sentry_sdk/utils.py

View check run for this annotation

@sentry/warden / warden: code-review

[QUR-VQR] sentry.is_localhost trusts forwarding headers regardless of proxy configuration (additional location)

The WSGI middleware uses `get_client_ip(environ)` and `HTTP_X_FORWARDED_HOST` to determine `sentry.is_localhost` without gating either on `use_x_forwarded_for`. A client able to supply these headers can make the attribute true even when forwarded-host support is disabled, potentially causing localhost inbound filters to discard the event. Avoid using untrusted forwarding headers for this determination, or use them only when explicitly trusted.

Check warning on line 2243 in sentry_sdk/utils.py

View check run for this annotation

@sentry/warden / warden: code-review

IPv6 loopback Host headers are not classified as localhost

`_is_localhost` splits Host and `X-Forwarded-Host` values at the first colon, so `[::1]:8080` becomes `[` and fails the local-domain check. The check also does not recognize `::1` as a host domain, even without a port. Consequently, these headers are classified as non-local unless the client IP independently matches; parse bracketed IPv6 literals and recognize the loopback address.

Check warning on line 2243 in sentry_sdk/utils.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[HAM-7BK] IPv6 loopback Host headers are not detected as localhost (additional location)

`_LOCAL_DOMAINS` omits `::1`, and host parsing uses `split(":")[0]`, so Host values like `[::1]` or `[::1]:8080` never match unless `client_ip` is already loopback.
Comment thread
sentrivana marked this conversation as resolved.
Resolved
Comment on lines +2241 to +2243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv6 loopback Host headers are not classified as localhost

_is_localhost splits Host and X-Forwarded-Host values at the first colon, so [::1]:8080 becomes [ and fails the local-domain check. The check also does not recognize ::1 as a host domain, even without a port. Consequently, these headers are classified as non-local unless the client IP independently matches; parse bracketed IPv6 literals and recognize the loopback address.

Evidence
  • SentryWsgiMiddleware passes the client IP, SERVER_NAME, HTTP_HOST, and HTTP_X_FORWARDED_HOST to _is_localhost.
  • _is_localhost checks client IPs against _LOCAL_IPS, but splits each host header at the first colon before calling _host_matches_local_domain.
  • For [::1]:8080, that split produces [. The matcher only accepts localhost and 127.0.0.1, so ::1 would not match even if the brackets were parsed correctly.
  • With a non-loopback client IP, an IPv6 loopback host header therefore produces a false result.

Identified by Warden · code-review · WMM-RCU


return False


# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support
@contextmanager
def nullcontext() -> "Iterator[None]":
Expand Down
51 changes: 51 additions & 0 deletions tests/integrations/wsgi/test_wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -1333,3 +1333,54 @@
(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

Check warning on line 1345 in tests/integrations/wsgi/test_wsgi.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[HAM-7BK] IPv6 loopback Host headers are not detected as localhost (additional location)

`_LOCAL_DOMAINS` omits `::1`, and host parsing uses `split(":")[0]`, so Host values like `[::1]` or `[::1]:8080` never match unless `client_ip` is already loopback.
(
{
"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,
Comment thread
sentrivana marked this conversation as resolved.
Resolved
),

Check warning on line 1361 in tests/integrations/wsgi/test_wsgi.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[RKT-Z79] sentry.is_localhost trusts spoofable forwarding headers (additional location)

In `wsgi.py`, `_is_localhost()` always uses `get_client_ip()` (X-Forwarded-For) and `HTTP_X_FORWARDED_HOST` without gating on `use_x_forwarded_for`, so clients can force `sentry.is_localhost=True` and get events dropped by localhost inbound filters.
Comment thread
sentrivana marked this conversation as resolved.
Resolved
],
)
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", [])
Comment thread
sentrivana marked this conversation as resolved.
Resolved
return ["woof"]

sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream")
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
51 changes: 51 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Components,
Dsn,
_get_installed_modules,
_is_localhost,
datetime_from_isoformat,
ensure_integration_enabled,
env_to_bool,
Expand Down Expand Up @@ -1409,3 +1410,53 @@

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),
# URL host (subdomain-aware matching)
({"url_host": "localhost"}, True),
({"url_host": "127.0.0.1"}, True),
Comment thread
sentrivana marked this conversation as resolved.
Resolved
({"url_host": "foo.localhost"}, True),
({"url_host": "bar.foo.localhost"}, True),
({"url_host": "foolocalhost"}, False),
({"url_host": "notlocalhost"}, False),
({"url_host": "example.com"}, False),
Comment thread
sentrivana marked this conversation as resolved.
Resolved
# 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": "foo.localhost"}, True),
({"host_header": "foo.localhost:8080"}, True),
({"host_header": "example.com"}, False),

Check warning on line 1439 in tests/test_utils.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[HAM-7BK] IPv6 loopback Host headers are not detected as localhost (additional location)

`_LOCAL_DOMAINS` omits `::1`, and host parsing uses `split(":")[0]`, so Host values like `[::1]` or `[::1]:8080` never match unless `client_ip` is already loopback.
({"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": "foo.localhost"}, True),
({"forwarded_host_header": "example.com"}, False),

Check warning on line 1446 in tests/test_utils.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[RKT-Z79] sentry.is_localhost trusts spoofable forwarding headers (additional location)

In `wsgi.py`, `_is_localhost()` always uses `get_client_ip()` (X-Forwarded-For) and `HTTP_X_FORWARDED_HOST` without gating on `use_x_forwarded_for`, so clients can force `sentry.is_localhost=True` and get events dropped by localhost inbound filters.
# 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
Loading