Skip to content
Draft
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
31 changes: 27 additions & 4 deletions src/sentry/api/endpoints/project_custom_inbound_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
ValidationErrorResponse,
as_validation_errors,
)
from sentry.ingest.inbound_filters import get_supported_condition_types
from sentry.ingest.inbound_filters import (
get_supported_condition_types,
is_release_version,
parse_release_comparison,

Check warning on line 31 in src/sentry/api/endpoints/project_custom_inbound_filters.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Catch RelayError when parsing user-supplied release values

A nonblank release value that causes Relay's `parse_release` to raise `RelayError` escapes `is_release_version` during custom filter validation, so the API returns a server error instead of a validation response. Catch `RelayError` and return `False`, matching the release model's defensive parsing: ```python try: return parse_release(release, json_loads=orjson.loads).get("version_parsed") is not None except RelayError: return False ```
)
from sentry.models.custominboundfilter import (
ConditionType,
CustomInboundFilter,
Expand Down Expand Up @@ -115,19 +119,38 @@
allow_empty=False,
help_text=(
"Glob patterns the field is matched against. The condition matches when any "
"pattern matches, so multiple values act as OR."
"pattern matches, so multiple values act as OR. A `release` value that is a "
"version, such as `1.2.0` or `myapp@1.2.0`, or that starts with `>`, `>=`, `<`, "
"`<=` or `=`, compares versions instead of matching the text. `ip_address` "
"values are addresses or CIDR ranges."
),
)

def validate(self, attrs: CustomInboundFilterCondition) -> CustomInboundFilterCondition:
# Relay drops an entry it cannot parse as an address or range, so a typo would
# silently disable part of the filter.
# Relay never matches an entry it cannot parse, so a typo would silently
# disable part of the filter.
if attrs["type"] == ConditionType.IP_ADDRESS:
invalid = [value for value in attrs["value"] if not _is_ip_address_or_range(value)]
if invalid:
raise serializers.ValidationError(
{"value": f"{', '.join(invalid)} is not an IP address or CIDR range."}
)
if attrs["type"] == ConditionType.RELEASE:
invalid = [
value
for value in attrs["value"]
if (comparison := parse_release_comparison(value))
and not is_release_version(comparison.release)
Comment on lines +138 to +143

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.

Release comparison validation can 500 on RelayError from parse_release

Wrap is_release_version() so parse_release RelayError returns False; otherwise invalid comparison values like >=package@1.0.0-@ crash this serializer with a 500 instead of a 400.

Evidence
  • The new RELEASE branch calls is_release_version(comparison.release) on each user-supplied comparison value.
  • is_release_version() invokes sentry_relay.processing.parse_release with no try/except.
  • Release.is_semver_version() and other call sites catch RelayError from parse_release ("invalid legacy releases") and treat it as non-semver.
  • An uncaught RelayError here escapes DRF validation and becomes a 500 instead of the intended validation error.

Identified by Warden · sentry-backend-bugs · FYL-YBT

]
if invalid:
raise serializers.ValidationError(
{
"value": (
f"{', '.join(invalid)} does not compare against a version "
"such as 1.2.0 or myapp@1.2.0."
)
}
)
return attrs


Expand Down
93 changes: 87 additions & 6 deletions src/sentry/ingest/inbound_filters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import re
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast
from typing import Any, Literal, cast

import orjson
from django.conf import settings
from rest_framework import serializers
from sentry_relay.processing import parse_release

from sentry.constants import SEMVER_FAKE_PACKAGE
from sentry.models.custominboundfilter import (
ConditionType,
CustomInboundFilter,
Expand Down Expand Up @@ -562,7 +566,7 @@
)


# Builds the Relay condition that matches one filter condition's glob values.
# Builds the Relay condition that matches one filter condition's values.
_ConditionMatcher = Callable[[list[str]], RuleCondition]

# The matcher for each condition type a data type supports.
Expand All @@ -586,6 +590,83 @@
_client_ip_matcher = _cidr_matcher("envelope.client_ip")


SemverComparator = Literal["eq", "gt", "gte", "lt", "lte"]

_RELEASE_COMPARATORS: Mapping[str, SemverComparator] = {
">=": "gte",
"<=": "lte",
">": "gt",
"<": "lt",
"=": "eq",
}

_RELEASE_COMPARISON_RE = re.compile(r"(>=|<=|>|<|=)\s*(.+)")


@dataclass(frozen=True)
class ReleaseComparison:
comparator: SemverComparator
# The release to compare against, such as `1.2.0` or `myapp@1.2.0`.
release: str


def parse_release_comparison(value: str) -> ReleaseComparison | None:
"""
Reads a release condition value that compares versions: one with a leading
comparator, such as `>=1.2.0` or `<myapp@2.0`, or a plain release with a version,
such as `1.2.0`, which compares as equal. Returns None for any other value, which
is a glob pattern.

A value with a comparator is returned whether or not its release carries a
version, so that the caller can reject one that does not.
"""
match = _RELEASE_COMPARISON_RE.fullmatch(value)
if match is not None:
return ReleaseComparison(_RELEASE_COMPARATORS[match.group(1)], match.group(2))
if is_release_version(value):
return ReleaseComparison("eq", value)
return None


def is_release_version(release: str) -> bool:
"""
Whether Relay reads a version out of the release, so that it can compare it.
A release such as `1.2`, `1.2.3.4-rc.1` or `myapp@1.2.0` has one. A commit hash
or a glob pattern has none.
"""
if "@" not in release:
release = f"{SEMVER_FAKE_PACKAGE}@{release}"
return parse_release(release, json_loads=orjson.loads).get("version_parsed") is not None

Check warning on line 639 in src/sentry/ingest/inbound_filters.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

[UVH-CHY] Catch RelayError when parsing user-supplied release values (additional location)

A nonblank release value that causes Relay's `parse_release` to raise `RelayError` escapes `is_release_version` during custom filter validation, so the API returns a server error instead of a validation response. Catch `RelayError` and return `False`, matching the release model's defensive parsing: ```python try: return parse_release(release, json_loads=orjson.loads).get("version_parsed") is not None except RelayError: return False ```


def _release_matcher(name: str) -> _ConditionMatcher:
# Glob values share one condition; each version comparison is a condition of
# its own, as Relay takes one comparator and release per `semver` condition.
def match(values: list[str]) -> RuleCondition:
globs: list[str] = []
conditions: list[RuleCondition] = []
for value in values:
comparison = parse_release_comparison(value)
if comparison is None:
globs.append(value)
else:
conditions.append(
{
"op": "semver",
"name": name,
"comparator": comparison.comparator,
"value": comparison.release,
}
)
if globs:
conditions.insert(0, {"op": "glob", "name": name, "value": globs})
if len(conditions) == 1:
return conditions[0]
return {"op": "or", "inner": conditions}

return match


_CONDITION_MATCHERS: Mapping[
ConditionType,
_ConditionMatcher | Mapping[DataType, _ConditionMatcher],
Expand All @@ -603,10 +684,10 @@
DataType.METRIC: _field_matcher("trace_metric.name"),
},
ConditionType.RELEASE: {
DataType.ERROR: _field_matcher("event.release"),
DataType.LOG: _field_matcher("log.attributes.sentry.release.value"),
DataType.METRIC: _field_matcher("trace_metric.attributes.sentry.release.value"),
DataType.SPAN: _field_matcher("span.attributes.sentry.release.value"),
DataType.ERROR: _release_matcher("event.release"),
DataType.LOG: _release_matcher("log.attributes.sentry.release.value"),
DataType.METRIC: _release_matcher("trace_metric.attributes.sentry.release.value"),
DataType.SPAN: _release_matcher("span.attributes.sentry.release.value"),
},
ConditionType.IP_ADDRESS: _client_ip_matcher,
}
Expand Down
15 changes: 15 additions & 0 deletions src/sentry/relay/types/rule_condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ class CidrCondition(TypedDict):
value: list[str]


class SemverCondition(TypedDict):
"""Release version comparison condition

Compares the version of the release in a field against `value`, a release such
as `1.2.0` or `myapp@1.2.0`. A value with a package only matches releases of that
package. A field or value without a version, such as a commit hash, never matches.
"""

op: Literal["semver"]
name: str
comparator: Literal["eq", "gt", "gte", "lt", "lte"]
value: str


class IterableCondition(TypedDict):
"""Condition for iterating over a list and applying a nested condition"""

Expand Down Expand Up @@ -117,4 +131,5 @@ class NotCondition(TypedDict):
LtCondition,
GlobCondition,
CidrCondition,
SemverCondition,
]
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,42 @@ def test_rejects_ip_address_that_does_not_parse(self) -> None:
"10.0.0.*, nope is not an IP address or CIDR range."
)

def test_post_release_version_comparison(self) -> None:
"""A release value that starts with a comparator is stored as typed."""
conditions = [{"type": "release", "value": [">=1.2.0", "<myapp@2.0", "1.*"]}]

with self.feature(self.features), outbox_runner():
response = self.get_success_response(
self.organization.slug,
self.project.slug,
method="post",
name="Old versions",
dataType="all",
conditions=conditions,
status_code=201,
)

custom_filter = CustomInboundFilter.objects.get(id=response.data["id"])
assert response.data["conditions"] == conditions
assert custom_filter.conditions == conditions

def test_rejects_release_comparison_without_version(self) -> None:
with self.feature(self.features):
response = self.get_error_response(
self.organization.slug,
self.project.slug,
method="post",
name="Typo",
dataType="error",
conditions=[
{"type": "release", "value": [">=1.2.0", ">2*", "<a4b7e0f9c2d1", "1.*"]}
],
)

assert str(response.data["conditions"][0]["value"][0]) == (
">2*, <a4b7e0f9c2d1 does not compare against a version such as 1.2.0 or myapp@1.2.0."
)

def test_catch_all_needs_no_ingestion_feature(self) -> None:
"""The catch-all filters whichever data types the organization ingests."""
with self.feature(self.features):
Expand Down
Loading
Loading