ref(discover): Restrict the fn argument of fn_span_count - #121911
Merged
Conversation
The `fn` argument of `fn_span_count` lands in a ClickHouse function name position, and was declared as a plain `SnQLStringArg` with no `allowed_strings`, so its `normalize()` passed the caller's value through unchanged. Constrain it to an allowlist of the functions it makes sense to wrap a span count in. Anything else is now rejected during query building with a 400 rather than being forwarded. Addresses VULN-2528.
phacops
force-pushed
the
claude/vuln-2528-fn-span-count-fn-arg
branch
from
August 13, 2026 01:12
e1ba1cc to
96ba3b8
Compare
phacops
enabled auto-merge (squash)
August 13, 2026 01:21
phacops
pushed a commit
to getsentry/snuba
that referenced
this pull request
Aug 13, 2026
ClickHouse connections for user-facing reads and the read-only admin tools ran at readonly=0, so anything that reached an unintended expression position in a query was evaluated with full write privileges. Set readonly=2 on the QUERY and QUERYLOG profiles. 2 rather than 1 because Snuba sends per-query settings on these connections and readonly=1 forbids that; readonly=2 still allows settings but not writes, and it cannot be lowered back to 0 from inside a query. Sudo admin statements (SYSTEM, ALTER, DROP, OPTIMIZE) are not reads and would be rejected under those profiles, so add a separate ADMIN_SUDO profile and route sudo system queries to it. It stays gated on the existing ExecuteSudoSystemQuery role. Ref VULN-2528. The ClickHouse function allowlist previously on this branch is dropped: the specific injection it backstopped is fixed at the source in getsentry/sentry#121911, and the AllowedFunctionValidator that would consume the list defaults to off, so the list had no runtime effect. It belongs with the work that turns that validator on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwW2vFauZMzgphPuS8KiKp
Add the median and the standard percentile levels to FN_SPAN_COUNT_FUNCTIONS, matching the levels the p50..p99 functions already use. The levels are matched as written, so the allowlist stays an exact string comparison.
phacops
pushed a commit
to getsentry/snuba
that referenced
this pull request
Aug 13, 2026
ClickHouse connections for user-facing reads and the read-only admin tools ran at readonly=0, so anything that reached an unintended expression position in a query was evaluated with full write privileges. Set readonly=2 on the QUERY and QUERYLOG profiles. 2 rather than 1 because Snuba sends per-query settings on these connections and readonly=1 forbids that; readonly=2 still allows settings but not writes, and it cannot be lowered back to 0 from inside a query. Sudo admin statements (SYSTEM, ALTER, DROP, OPTIMIZE) are not reads and would be rejected under those profiles, so add a separate ADMIN_SUDO profile and route sudo system queries to it. It stays gated on the existing ExecuteSudoSystemQuery role. Ref VULN-2528. The ClickHouse function allowlist previously on this branch is dropped: the specific injection it backstopped is fixed at the source in getsentry/sentry#121911, and the AllowedFunctionValidator that would consume the list defaults to off, so the list had no runtime effect. It belongs with the work that turns that validator on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwW2vFauZMzgphPuS8KiKp
phacops
pushed a commit
to getsentry/snuba
that referenced
this pull request
Aug 13, 2026
ClickHouse connections for user-facing reads and the read-only admin tools ran at readonly=0, so a query that reached an unintended expression position was evaluated with full write privileges. Set readonly=2 on the QUERY and QUERYLOG profiles. 2 rather than 1 because Snuba sends per-query settings on these connections and readonly=1 rejects that; readonly=2 permits settings but not writes, and it cannot be lowered back to 0 from inside a query. TRACING and CARDINALITY_ANALYZER were already at readonly=2, and a test now asserts all four stay that way. Sudo admin statements (SYSTEM, ALTER, DROP, OPTIMIZE) are not reads and would be rejected under a readonly profile, so add a separate ADMIN_SUDO profile and route sudo system queries to it. This is not a privilege change: sudo remains gated on the existing ExecuteSudoSystemQuery role. Ref VULN-2528. The ClickHouse function allowlist previously on this branch is dropped: the injection it backstopped is fixed at its source in getsentry/sentry#121911, and the AllowedFunctionValidator that would consume the list defaults to off, so the list had no runtime effect. It belongs with the work that turns that validator on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwW2vFauZMzgphPuS8KiKp
oioki
approved these changes
Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses
VULN-2528.What changed
fn_span_countinDiscoverDatasetConfig(src/sentry/search/events/datasets/discover.py) declared its second argument asSnQLStringArg("fn"). With noallowed_strings,StringArg.normalize()returns the caller's value unchanged, and that value is used as the name of the generated ClickHouse function.This restricts the argument to an allowlist of the functions it makes sense to wrap a span count in:
Anything outside that set is now rejected during query building (
InvalidSearchQuery, i.e. a 400 from the events endpoints) instead of being forwarded.The percentile entries are matched as written — the allowlist stays a plain string comparison, so
quantile(0.42)is rejected like any other unlisted value. Passing a parameterised name through the function-name position is the same shape the shipped percentile functions already use (fields.pyp95isquantile(0.95);discover.pybuildsf"quantile({...})"), andFUNCTION_PATTERN+parse_argumentsresolvefn_span_count("db", quantile(0.95))to that argument unchanged.Why an allowlist rather than removing the function
The function has no callers inside this repository — no Python usage, no
static/usage, no tests or fixtures, and no default discover/dashboard queries. Removing it was the first option considered.However, Discover function names are part of the public query surface: they can be supplied directly through the events API and through query-authoring paths that build field expressions dynamically, neither of which leaves a reference in this repo. Sentry's own telemetry can't rule that usage out either — the events endpoints tag things like
query.datasetandquery.referrerbut do not record the requestedfieldvalues, so a successful call to this function leaves no searchable trace. (Searches over Sentry's own errors and logs forfn_span_countover 90 days return nothing, which rules out a crashing caller but not a working one.)Removal and
private=Trueare equally breaking for such a caller — both turn a working query into a 400. The allowlist is the only option that closes the argument injection while leaving legitimate use working, so that is what this does. The function is deliberately left non-private for the same reason.identityis included as the passthrough wrapper consistent with this being declared viasnql_columnrather thansnql_aggregate; it already exists as a function in its own right (sessions.py, and the legacyDiscoverFunctioninfields.py).Behaviour change for API consumers
A query passing an
fnvalue outside the allowlist now gets a 400 (fn_span_count(...): fn argument invalid: string must be one of [...]) instead of having the value forwarded. Queries using any of the allowed values are unaffected.Related audit
I checked the sibling
SnQLFunctiondefinitions in the same file and the other dataset configs for the same shape — a caller-supplied string reaching a ClickHouse function-name or column-name position withoutallowed_strings:modulo(discover.py) passesSnQLStringArg("column")into aColumn(...)name position. It is alreadyprivate=True, so it requires afunctions_aclgrant, and the intended column set isn't derivable from the code — left unchanged, flagged here for the owning team.Function(args[...])name position undersrc/sentry/search/events/datasets/is fed byConditionArg, which validates against a fixed list of conditions.SnQLStringArgarguments (count_op,trace_status_rate,http_response_rate,count_if,avg_if,to_other, …) are used in value positions, not name positions.Testing
Added four tests to
tests/sentry/search/events/builder/test_discover.py:test_fn_span_count_allowed_function—fn_span_count("db", sum)still resolves to the expected SnQL.test_fn_span_count_allowed_percentile—fn_span_count("db", quantile(0.95))resolves to aquantile(0.95)wrapper.test_fn_span_count_disallowed_percentile— an unlisted level such asquantile(0.42)is rejected.test_fn_span_count_disallowed_function— any other unlisted value raisesInvalidSearchQueryat query-build time rather than reaching ClickHouse.I could not run anything locally: the environment this was prepared in has no Python virtualenv or installed dependencies (and no postgres/kafka/clickhouse devservices), so
pytestcould not run at all. The change was written against a static read of the code — tracingformat_as_arguments→StringArg.normalizefor the rejection path and error message, andFUNCTION_PATTERN/parse_arguments/get_function_alias_with_columnsfor the parsed arguments and expected aliases.CI was therefore the first execution of these tests, and it is green on the current commit: all 18 backend test shards,
backend typing,pre-commit lint, and the acceptance suite pass.