Skip to content

security(clickhouse): keep client-side readonly off server-enforced reads - #8293

Closed
phacops wants to merge 1 commit into
masterfrom
claude/clickhouse-expression-injection-fix-o7ga7i
Closed

security(clickhouse): keep client-side readonly off server-enforced reads#8293
phacops wants to merge 1 commit into
masterfrom
claude/clickhouse-expression-injection-fix-o7ga7i

Conversation

@phacops

@phacops phacops commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Ref VULN-2528.

This PR changed direction. It previously made Snuba send readonly=2 on its read profiles. Now that the API deployment's CLICKHOUSE_USER is provisioned with a server-side readonly=2 profile — as the admin read tools' CLICKHOUSE_READONLY_USER already is — that approach is not just redundant, it would break every read:

Cannot modify 'readonly' setting in readonly mode

So the client-side work is reverted in full. What remains is a guard so it cannot come back, plus devservices support for the constrained user.

Why server-side won

Read-only can be enforced two ways, and they are mutually exclusive on the same connection:

Enforced by Can a query escape it?
Client-side (readonly=2 in query settings) Snuba, per query Yes — any read path built without the profile misses it
Server-side (the ClickHouse user's profile) ClickHouse No

Server-side is strictly stronger, and it turned out to be available: the API process never writes to ClickHouse, so its CLICKHOUSE_USER can be read-only without affecting ingestion. Its DELETE endpoints (views.py:340, endpoint_delete_trace_items.py:160) produce to Kafka; the actual DELETE FROM runs in the separate lw_deletions consumer. Migrations, the replacer, optimize and CLEANUP are all separate deployments on the writable credential.

Note for provisioning: that user needs readonly=2, not readonly=1. Reads send per-query settings (max_threads, load_balancing, query_id) and readonly=1 rejects any setting change.

What is in this PR

  • A test pinning that the QUERY and QUERYLOG profiles send no readonly setting, with the reasoning inline, so re-adding one fails in CI rather than in production. This is the durable part: the whole detour happened because nothing encoded which paths are server-enforced.
  • devservices/clickhouse/users.xml gains a readonly user on a readonly=2 profile, so the constrained path can be exercised locally and the expected production shape is documented.

What was reverted

  • readonly=2 on QUERY and QUERYLOG
  • The ADMIN_READ profile and the admin call-site changes, which existed only to shield the admin read paths from QUERY's readonly=2
  • The ADMIN_SUDO profile, which existed only to keep sudo statements off a readonly profile

TRACING and CARDINALITY_ANALYZER keep their pre-existing readonly=2. They authenticate as CLICKHOUSE_TRACE_USER, which is not constrained server-side, so for them the client-side setting is the only guard there is. If that user is ever given a readonly profile, they will need the same treatment — worth a follow-up.

Known trade-off

Deployments that do not provision a read-only user for the API — self-hosted, most likely — no longer get the client-side readonly=2 and so keep running reads at readonly=0. That is a deliberate cost of the two mechanisms being mutually exclusive: Snuba cannot know whether the user it authenticates as is already constrained, so it cannot safely send the setting "just in case". The actual injection VULN-2528 reported is fixed at its source in getsentry/sentry#121911, which applies to every deployment regardless.

Testing

The suite passes locally (46 tests in tests/clickhouse/test_connect.py; the one error there is a pre-existing ClickHouse-dependent test, and no ClickHouse or Docker daemon was available in my environment). Nothing here exercises a real constrained user yet — wiring a CI job for that is follow-up, since the test suite writes to ClickHouse via migrations and fixtures and so cannot be pointed at a read-only user wholesale.

Not included

The ClickHouse function allowlist from an earlier revision of this branch was dropped. The injection it backstopped is fixed in getsentry/sentry#121911, and AllowedFunctionValidator defaults to off, so the list had no runtime effect. Turning that validator on needs its own soak against the invalid_funcs metric. For whoever picks it up: of the five values Sentry now permits, only identity is missing from Snuba's list today.

@phacops
phacops requested a review from a team as a code owner August 12, 2026 15:33
Comment thread snuba/query/functions.py Outdated

phacops commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

The alias finding was correct, and it's structural rather than a gap to patch — CURRENT_USER and fullHostName lowercase to names the denylist never listed, and no enumeration of ClickHouse aliases stays complete.

Fixed by dropping the denylist and enforcing the GLOBAL_VALID_FUNCTIONS allowlist instead (604ca51). An allowlist rejects every alias for free by never listing it, and also rejects helpers the denylist had to permit, like reinterpretAsUInt64. The allowlist was measured against its callers before being enforced — 127 function names emitted by getsentry/sentry, plus Snuba's own entity function mappers — and function-validator.enabled is now a kill switch rather than an opt-in gate. Details in the updated PR description.


Generated by Claude Code

phacops commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Correcting my comment above before it misleads a reviewer: function-validator.enabled is not a kill switch, and enforcement is not on by default.

That revision was reverted. The option keeps its shipped default: false and stays an opt-in gate, to be turned on out of band, so there is no options-schema change left in this PR. Practically: on merge, an unlisted function name is still only counted, and the injection stays open on the Snuba side until the option is set. The allowlist work here is what makes that flip safe rather than breaking legitimate queries.

The PR description is current; the earlier comment is not.


Generated by Claude Code

@phacops
phacops force-pushed the claude/clickhouse-expression-injection-fix-o7ga7i branch from d333102 to 48febd8 Compare August 13, 2026 01:42
@phacops phacops changed the title security(query): reject ClickHouse introspection functions and make user reads readonly security(query): make user-facing reads readonly Aug 13, 2026
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

VULN-2528

@phacops
phacops force-pushed the claude/clickhouse-expression-injection-fix-o7ga7i branch from 48febd8 to 67d5416 Compare August 13, 2026 01:46
Comment thread snuba/clusters/cluster.py Outdated
@phacops
phacops force-pushed the claude/clickhouse-expression-injection-fix-o7ga7i branch from 67d5416 to 878b124 Compare August 13, 2026 01:49
@phacops phacops changed the title security(query): make user-facing reads readonly security(clickhouse): run user-facing reads with readonly=2 Aug 13, 2026
phacops pushed a commit that referenced this pull request Aug 13, 2026
The admin read-only tools authenticate as CLICKHOUSE_READONLY_USER, a
dedicated credential that is expected to be readonly server-side. Sending
readonly as a client setting to a user already in readonly mode makes
ClickHouse reject the query ("Cannot modify 'readonly' setting in readonly
mode"), and under a readonly=1 profile it rejects any setting at all. That
would have taken out System Queries and the querylog tool, and CI could not
catch it because CLICKHOUSE_READONLY_USER defaults to the writable "default"
user.

Split the two consumers of the QUERY profile apart. QUERY keeps readonly=2
and is now used only by the user-facing read path, whose cluster credential
is shared with the batch writer and so cannot be constrained server-side --
a client-side setting is the only lever available there, and that is the
path VULN-2528 is about. Admin reads move to a new ADMIN_READ profile that
sends no settings, and QUERYLOG goes back to sending none; both rely on the
server-side readonly user, which is stronger than a client-side setting.

The admin connection helpers now assert on ADMIN_READ instead of QUERY, so
an admin caller cannot reach for the readonly=2 profile by accident.

Reported by Cursor Bugbot on #8293.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwW2vFauZMzgphPuS8KiKp
@phacops
phacops force-pushed the claude/clickhouse-expression-injection-fix-o7ga7i branch from d440f81 to 4ed244d Compare August 13, 2026 03:23
@phacops phacops changed the title security(clickhouse): run user-facing reads with readonly=2 security(clickhouse): keep client-side readonly off server-enforced reads Aug 13, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4ed244d. Configure here.

assert "readonly" not in profile.value.settings, (
f"{profile.name} runs as a server-side read-only user; sending readonly "
"would make ClickHouse reject the query"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

QUERY treated as server-side readonly

High Severity

The new test forbids readonly on QUERY on the claim that API CLICKHOUSE_USER is already server-side readonly. QUERY is still what get_reader() uses, and that path authenticates as the writable cluster user shared with get_batch_writer(), not CLICKHOUSE_READONLY_USER. The local readonly user is only reachable by setting CLICKHOUSE_READONLY_USER, which does not change user-facing reads. That locks in VULN-2528 instead of applying client-side readonly=2 on the user-facing path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4ed244d. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Half right, and the half that's right was a real error — fixed in 189771a.

Correct, and my mistake: the devservices addition could not do what its comment claimed. I wrote that setting CLICKHOUSE_READONLY_USER=readonly would exercise "the user-facing read path", which is false — that variable only selects credentials for the admin helpers in snuba/admin/clickhouse/common.py. The user-facing path authenticates as the cluster credential from CLUSTERS, i.e. CLICKHOUSE_USER, exactly as you say.

It also could not have worked even with the right variable: devservices/config.yml mounts that file as the container's users.xml, and the snuba service there runs devserver — API, consumers and ENABLE_AUTORUN_MIGRATION_SEARCH_ISSUES: 1 — all under one credential, so constraining it would break migrations and every consumer locally. CI doesn't mount the file at all. The whole addition was inert at best and misleading at worst, so it's dropped. The PR is now the single test.

Where I disagree: the test does not lock in VULN-2528. You're right that QUERY is what get_reader() uses and that nothing in this repository points it at a constrained user — that's precisely the point. Read-only for that path is being enforced by provisioning: the API deployment's CLICKHOUSE_USER is set to a user with a server-side readonly=2 profile. That's config you can't see from here, which is why the conclusion looks wrong from inside the diff.

To be straight about the evidence: I'm relying on the maintainer's statement that this provisioning is now in place, not on anything I verified myself. If it turns out not to be, this test is wrong and readonly=2 should go back on QUERY — so the test's docstring names the assumption explicitly rather than leaving it implicit.

The two mechanisms cannot both be used. Sending readonly=2 to a user already in readonly mode fails with Cannot modify 'readonly' setting in readonly mode, and a readonly=1 profile rejects any setting at all — which is the same failure mode you correctly flagged on the admin paths earlier in this PR. Server-side is also strictly stronger, since a query cannot opt out of it.


Generated by Claude Code

…eads

VULN-2528's read-only finding is fixed by provisioning, not by code: the API
deployment's CLICKHOUSE_USER now has a server-side readonly=2 profile, as the
admin read tools' CLICKHOUSE_READONLY_USER does. Server-side enforcement is
strictly stronger than a setting Snuba sends, because a query cannot opt out
of it.

That makes the client-side approach this branch previously carried -- sending
readonly=2 on the QUERY and QUERYLOG profiles -- not merely redundant but
breaking: a user already in readonly mode rejects the query with "Cannot
modify 'readonly' setting in readonly mode", and a readonly=1 profile rejects
any setting at all. So it is reverted in full, along with the ADMIN_READ and
ADMIN_SUDO profiles that existed only to shield the admin paths from it.

What is left is the part that keeps it from coming back: a test pinning that
the QUERY and QUERYLOG profiles send no readonly setting, with the reason, so
that re-adding one fails in CI rather than in production. TRACING and
CARDINALITY_ANALYZER keep their readonly=2 -- they authenticate as
CLICKHOUSE_TRACE_USER, which is not constrained server-side, so there the
client-side setting is the only guard.

devservices gains a readonly user on a readonly=2 profile so the constrained
path can be exercised locally, and to document the shape production is
expected to be provisioned in. Wiring a CI job that runs the read paths as
that user is follow-up: the test suite writes to ClickHouse (migrations,
fixtures), so it cannot simply be pointed at a read-only user wholesale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwW2vFauZMzgphPuS8KiKp
@phacops
phacops force-pushed the claude/clickhouse-expression-injection-fix-o7ga7i branch from 4ed244d to 189771a Compare August 13, 2026 03:28

phacops commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Closing — nothing worth merging is left here.

VULN-2528 is addressed in two other places instead:

  • The injection itself is fixed at its source in ref(discover): Restrict the fn argument of fn_span_count sentry#121911, which constrains fn_span_count's fn argument to ["identity", "sum", "avg", "min", "max"].
  • The readonly=0 finding is handled by provisioning: the Snuba API deployment's CLICKHOUSE_USER runs with a server-side readonly=2 profile. Server-side enforcement is strictly stronger than a setting Snuba sends, since a query cannot opt out of it — and the two are mutually exclusive, because sending readonly to a user already in readonly mode fails with Cannot modify 'readonly' setting in readonly mode.

That left this PR with a single test asserting behaviour that is already true on master, which is not worth carrying.

For anyone arriving from the ticket, the parts worth remembering:

  • Client-side and server-side readonly cannot be combined on the same connection.
  • A read-only ClickHouse user for Snuba's read paths needs readonly=2, not readonly=1: reads send per-query settings (max_threads, load_balancing, query_id) and readonly=1 rejects any setting change.
  • TRACING and CARDINALITY_ANALYZER still send readonly=2 as a client setting on CLICKHOUSE_TRACE_USER. If that user is ever given a server-side readonly profile, those admin tools break the same way — a live follow-up.
  • The ClickHouse function allowlist that appeared on an early revision of this branch was dropped. AllowedFunctionValidator defaults to off, so it had no runtime effect; enabling it needs its own soak against the invalid_funcs metric. Note for whoever does that: of the five values Sentry now permits, only identity is missing from Snuba's list today.

Thanks to Cursor Bugbot, which caught two genuine problems on this branch: readonly=2 breaking the admin read tools, and a devservices addition whose comment described behaviour it did not have.


Generated by Claude Code

@phacops phacops closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants