From 4413635d8b2d9cd479447de7ec7d1302b2a997e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:33:41 +0000 Subject: [PATCH 1/4] fix(rpc): escape dangling backslashes in LIKE patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-supplied LIKE patterns forwarded from Sentry can contain a backslash that does not begin a valid ClickHouse escape sequence (\%, \_ or \\) — most commonly a trailing backslash such as '%Background\'. ClickHouse rejects these with CANNOT_PARSE_ESCAPE_SEQUENCE, failing the whole query. Escape each dangling backslash to a literal backslash before building the LIKE/NOT_LIKE expression, so it matches literally instead of erroring. Well-formed escape sequences are left untouched. Applied to string keys, array keys, and any-attribute filters. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QTo7u8JAEBC4he6hvX9vJu --- snuba/web/rpc/common/common.py | 61 +++++++++++++++++++++++++---- tests/web/rpc/test_common.py | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index 403f24475c..11a2f56231 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -51,6 +51,7 @@ Expression, FunctionCall, Lambda, + Literal, SubscriptableReference, ) from snuba.state.sentry_options import get_option @@ -553,6 +554,47 @@ def _attribute_value_to_expression(v: AttributeValue) -> Expression: ) +def _escape_dangling_like_backslashes(pattern: str) -> str: + """Escape backslashes that do not begin a valid ClickHouse LIKE escape sequence. + + ClickHouse uses ``\\`` as the escape character in LIKE patterns and only accepts + ``\\%``, ``\\_`` and ``\\\\`` as escape sequences. Any other backslash — most + commonly a trailing backslash at the end of the pattern — is rejected with + ``CANNOT_PARSE_ESCAPE_SEQUENCE``. Sentry forwards user-supplied text as a LIKE + pattern, so those backslashes are meant literally; we escape each dangling + backslash to ``\\\\`` so ClickHouse matches a literal backslash instead of erroring. + Well-formed escape sequences are left untouched. + """ + result: list[str] = [] + i = 0 + n = len(pattern) + while i < n: + char = pattern[i] + if char == "\\": + nxt = pattern[i + 1] if i + 1 < n else None + if nxt in ("%", "_", "\\"): + # Valid escape sequence, keep both characters as-is. + result.append(char) + result.append(nxt) # type: ignore[arg-type] + i += 2 + continue + # Dangling backslash: escape it so it matches a literal backslash. + result.append("\\\\") + i += 1 + continue + result.append(char) + i += 1 + return "".join(result) + + +def _sanitize_like_pattern_expression(expr: Expression) -> Expression: + """Sanitize a string LIKE-pattern literal so ClickHouse does not reject it with + ``CANNOT_PARSE_ESCAPE_SEQUENCE``. Non-string literals pass through unchanged.""" + if isinstance(expr, Literal) and isinstance(expr.value, str): + return literal(_escape_dangling_like_backslashes(expr.value)) + return expr + + _NEGATIVE_OPS = { AnyAttributeFilter.OP_NOT_EQUALS, AnyAttributeFilter.OP_NOT_LIKE, @@ -843,10 +885,11 @@ def _any_attribute_filter_to_expression( else: comparison = f.equals(x, v_expression) elif effective_op == AnyAttributeFilter.OP_LIKE: + like_pattern = _sanitize_like_pattern_expression(v_expression) if filt.ignore_case: - comparison = f.ilike(x, v_expression) + comparison = f.ilike(x, like_pattern) else: - comparison = f.like(x, v_expression) + comparison = f.like(x, like_pattern) elif effective_op == AnyAttributeFilter.OP_IN: if filt.ignore_case: if value_type == "val_str_array": @@ -1093,9 +1136,10 @@ def trace_item_filters_to_expression( expr_with_null = or_cond(expr, f.xor(f.isNull(k_expression), f.isNull(v_expression))) return expr_with_null if op == ComparisonFilter.OP_LIKE: + like_pattern = _sanitize_like_pattern_expression(v_expression) if k.type in ARRAY_TYPES: return _typed_array_like_expression( - k, v_expression, item_filter.comparison_filter.ignore_case + k, like_pattern, item_filter.comparison_filter.ignore_case ) if k.type != AttributeKey.Type.TYPE_STRING: raise BadSnubaRPCRequestException( @@ -1104,13 +1148,14 @@ def trace_item_filters_to_expression( comparison_function = f.ilike if item_filter.comparison_filter.ignore_case else f.like if _is_map_backed_key(k): value, exists = _map_backed_operands(k) - return and_cond(exists, comparison_function(value, v_expression)) - return comparison_function(k_expression, v_expression) + return and_cond(exists, comparison_function(value, like_pattern)) + return comparison_function(k_expression, like_pattern) if op == ComparisonFilter.OP_NOT_LIKE: + like_pattern = _sanitize_like_pattern_expression(v_expression) if k.type in ARRAY_TYPES: return not_cond( _typed_array_like_expression( - k, v_expression, item_filter.comparison_filter.ignore_case + k, like_pattern, item_filter.comparison_filter.ignore_case ) ) if k.type != AttributeKey.Type.TYPE_STRING: @@ -1121,11 +1166,11 @@ def trace_item_filters_to_expression( # Negation of OP_LIKE; an absent key is "not like". like_fn = f.ilike if item_filter.comparison_filter.ignore_case else f.like value, exists = _map_backed_operands(k) - return not_cond(and_cond(exists, like_fn(value, v_expression))) + return not_cond(and_cond(exists, like_fn(value, like_pattern))) comparison_function = ( f.notILike if item_filter.comparison_filter.ignore_case else f.notLike ) - expr = comparison_function(k_expression, v_expression) + expr = comparison_function(k_expression, like_pattern) # we redefine the way not like works for nulls # now null not like "%anything%" is true expr_with_null = or_cond(expr, f.isNull(k_expression)) diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index 878003bdc1..15fe9381a4 100644 --- a/tests/web/rpc/test_common.py +++ b/tests/web/rpc/test_common.py @@ -55,6 +55,7 @@ from snuba.web.rpc.common.common import ( _any_attribute_filter_to_expression, _comparison_can_match_column_default, + _escape_dangling_like_backslashes, add_existence_check_to_map_attribute_reads, attribute_key_to_expression, dedupe_and_conditions, @@ -239,6 +240,75 @@ def test_not_like_on_int_key_raises(self) -> None: trace_item_filters_to_expression(item_filter, attribute_key_to_expression) +class TestLikePatternEscaping: + """A user-supplied LIKE pattern may contain a backslash that is not part of a valid + ClickHouse escape sequence (``\\%``, ``\\_``, ``\\\\``) — most commonly a trailing + backslash. ClickHouse rejects those with ``CANNOT_PARSE_ESCAPE_SEQUENCE``, so we + escape each dangling backslash to a literal ``\\\\`` before building the expression.""" + + @pytest.mark.parametrize( + "pattern,expected", + [ + ("%Background\\", "%Background\\\\"), + ("%Received\\", "%Received\\\\"), + ("\\", "\\\\"), + ("a\\b", "a\\\\b"), # dangling backslash before a normal char + ("job%", "job%"), # no backslash, unchanged + ("%failed%", "%failed%"), # no backslash, unchanged + ("a\\%b", "a\\%b"), # valid escape of a wildcard, unchanged + ("a\\_b", "a\\_b"), # valid escape of a wildcard, unchanged + ("a\\\\b", "a\\\\b"), # already-escaped backslash, unchanged + ("", ""), + ], + ) + def test_escape_dangling_like_backslashes(self, pattern: str, expected: str) -> None: + assert _escape_dangling_like_backslashes(pattern) == expected + + def _like_filter( + self, + pattern: str, + op: ComparisonFilter.Op.ValueType = ComparisonFilter.OP_LIKE, + key_type: AttributeKey.Type.ValueType = AttributeKey.Type.TYPE_STRING, + ) -> TraceItemFilter: + return TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(type=key_type, name="sentry.body"), + op=op, + value=AttributeValue(val_str=pattern), + ) + ) + + def test_like_on_string_key_sanitizes_trailing_backslash(self) -> None: + result = trace_item_filters_to_expression( + self._like_filter("%Background\\"), attribute_key_to_expression + ) + assert "%Background\\\\" in _collect_literal_values(result) + assert "%Background\\" not in _collect_literal_values(result) + + def test_not_like_on_string_key_sanitizes_trailing_backslash(self) -> None: + result = trace_item_filters_to_expression( + self._like_filter("%Received\\", op=ComparisonFilter.OP_NOT_LIKE), + attribute_key_to_expression, + ) + assert "%Received\\\\" in _collect_literal_values(result) + + def test_like_on_array_key_sanitizes_trailing_backslash(self) -> None: + result = trace_item_filters_to_expression( + self._like_filter("%Background\\", key_type=AttributeKey.Type.TYPE_ARRAY_STRING), + attribute_key_to_expression, + ) + assert "%Background\\\\" in _collect_literal_values(result) + + def test_any_attribute_like_sanitizes_trailing_backslash(self) -> None: + result = _any_attribute_filter_to_expression( + AnyAttributeFilter( + op=AnyAttributeFilter.OP_LIKE, + value=AttributeValue(val_str="%Background\\"), + ) + ) + assert "%Background\\\\" in _collect_literal_values(result) + + def _collect_column_names(expr: Expression) -> set[str]: names: set[str] = set() From b5fc5da1305c52210ee417a480521bb89a644091 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:35:37 +0000 Subject: [PATCH 2/4] chore: remove inline comments Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QTo7u8JAEBC4he6hvX9vJu --- snuba/web/rpc/common/common.py | 2 -- tests/web/rpc/test_common.py | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index 11a2f56231..5c01d7233a 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -573,12 +573,10 @@ def _escape_dangling_like_backslashes(pattern: str) -> str: if char == "\\": nxt = pattern[i + 1] if i + 1 < n else None if nxt in ("%", "_", "\\"): - # Valid escape sequence, keep both characters as-is. result.append(char) result.append(nxt) # type: ignore[arg-type] i += 2 continue - # Dangling backslash: escape it so it matches a literal backslash. result.append("\\\\") i += 1 continue diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index 15fe9381a4..56abc5d41a 100644 --- a/tests/web/rpc/test_common.py +++ b/tests/web/rpc/test_common.py @@ -252,12 +252,12 @@ class TestLikePatternEscaping: ("%Background\\", "%Background\\\\"), ("%Received\\", "%Received\\\\"), ("\\", "\\\\"), - ("a\\b", "a\\\\b"), # dangling backslash before a normal char - ("job%", "job%"), # no backslash, unchanged - ("%failed%", "%failed%"), # no backslash, unchanged - ("a\\%b", "a\\%b"), # valid escape of a wildcard, unchanged - ("a\\_b", "a\\_b"), # valid escape of a wildcard, unchanged - ("a\\\\b", "a\\\\b"), # already-escaped backslash, unchanged + ("a\\b", "a\\\\b"), + ("job%", "job%"), + ("%failed%", "%failed%"), + ("a\\%b", "a\\%b"), + ("a\\_b", "a\\_b"), + ("a\\\\b", "a\\\\b"), ("", ""), ], ) From 4a476eda987f39c69d8a141743d944969b0fd952 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:36:56 +0000 Subject: [PATCH 3/4] refactor: use regex instead of while loop for LIKE escaping Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QTo7u8JAEBC4he6hvX9vJu --- snuba/web/rpc/common/common.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index 5c01d7233a..26aafe469b 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -1,4 +1,5 @@ import math +import re from collections.abc import Callable, Iterable from dataclasses import replace from datetime import UTC, datetime, timedelta @@ -554,6 +555,10 @@ def _attribute_value_to_expression(v: AttributeValue) -> Expression: ) +# A valid ClickHouse LIKE escape sequence (``\%``, ``\_`` or ``\\``) or a lone backslash. +_LIKE_ESCAPE_RE = re.compile(r"\\[%_\\]|\\") + + def _escape_dangling_like_backslashes(pattern: str) -> str: """Escape backslashes that do not begin a valid ClickHouse LIKE escape sequence. @@ -565,24 +570,9 @@ def _escape_dangling_like_backslashes(pattern: str) -> str: backslash to ``\\\\`` so ClickHouse matches a literal backslash instead of erroring. Well-formed escape sequences are left untouched. """ - result: list[str] = [] - i = 0 - n = len(pattern) - while i < n: - char = pattern[i] - if char == "\\": - nxt = pattern[i + 1] if i + 1 < n else None - if nxt in ("%", "_", "\\"): - result.append(char) - result.append(nxt) # type: ignore[arg-type] - i += 2 - continue - result.append("\\\\") - i += 1 - continue - result.append(char) - i += 1 - return "".join(result) + return _LIKE_ESCAPE_RE.sub( + lambda m: m.group(0) if len(m.group(0)) == 2 else "\\\\", pattern + ) def _sanitize_like_pattern_expression(expr: Expression) -> Expression: From 185a8ab6299a78fefc1d9891b701a1c579c8036b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:43:13 +0000 Subject: [PATCH 4/4] [getsentry/action-github-commit] Auto commit --- snuba/web/rpc/common/common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index 26aafe469b..a52e983e60 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -570,9 +570,7 @@ def _escape_dangling_like_backslashes(pattern: str) -> str: backslash to ``\\\\`` so ClickHouse matches a literal backslash instead of erroring. Well-formed escape sequences are left untouched. """ - return _LIKE_ESCAPE_RE.sub( - lambda m: m.group(0) if len(m.group(0)) == 2 else "\\\\", pattern - ) + return _LIKE_ESCAPE_RE.sub(lambda m: m.group(0) if len(m.group(0)) == 2 else "\\\\", pattern) def _sanitize_like_pattern_expression(expr: Expression) -> Expression: