security(clickhouse): keep client-side readonly off server-enforced reads - #8293
security(clickhouse): keep client-side readonly off server-enforced reads#8293phacops wants to merge 1 commit into
Conversation
|
The alias finding was correct, and it's structural rather than a gap to patch — Fixed by dropping the denylist and enforcing the Generated by Claude Code |
|
Correcting my comment above before it misleads a reviewer: That revision was reverted. The option keeps its shipped The PR description is current; the earlier comment is not. Generated by Claude Code |
d333102 to
48febd8
Compare
48febd8 to
67d5416
Compare
67d5416 to
878b124
Compare
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
d440f81 to
4ed244d
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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" | ||
| ) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 4ed244d. Configure here.
There was a problem hiding this comment.
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
4ed244d to
189771a
Compare
|
Closing — nothing worth merging is left here. VULN-2528 is addressed in two other places instead:
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:
Thanks to Cursor Bugbot, which caught two genuine problems on this branch: Generated by Claude Code |


Ref VULN-2528.
This PR changed direction. It previously made Snuba send
readonly=2on its read profiles. Now that the API deployment'sCLICKHOUSE_USERis provisioned with a server-sidereadonly=2profile — as the admin read tools'CLICKHOUSE_READONLY_USERalready is — that approach is not just redundant, it would break every read: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:
readonly=2in query settings)Server-side is strictly stronger, and it turned out to be available: the API process never writes to ClickHouse, so its
CLICKHOUSE_USERcan be read-only without affecting ingestion. ItsDELETEendpoints (views.py:340,endpoint_delete_trace_items.py:160) produce to Kafka; the actualDELETE FROMruns in the separatelw_deletionsconsumer. Migrations, the replacer,optimizeandCLEANUPare all separate deployments on the writable credential.Note for provisioning: that user needs
readonly=2, notreadonly=1. Reads send per-query settings (max_threads,load_balancing,query_id) andreadonly=1rejects any setting change.What is in this PR
QUERYandQUERYLOGprofiles send noreadonlysetting, 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.xmlgains areadonlyuser on areadonly=2profile, so the constrained path can be exercised locally and the expected production shape is documented.What was reverted
readonly=2onQUERYandQUERYLOGADMIN_READprofile and the admin call-site changes, which existed only to shield the admin read paths fromQUERY'sreadonly=2ADMIN_SUDOprofile, which existed only to keep sudo statements off a readonly profileTRACINGandCARDINALITY_ANALYZERkeep their pre-existingreadonly=2. They authenticate asCLICKHOUSE_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=2and so keep running reads atreadonly=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
AllowedFunctionValidatordefaults to off, so the list had no runtime effect. Turning that validator on needs its own soak against theinvalid_funcsmetric. For whoever picks it up: of the five values Sentry now permits, onlyidentityis missing from Snuba's list today.