Skip to content

feat(security): erase ClickHouse data as a dedicated user, not the superuser - #66

Merged
marselsel merged 2 commits into
mainfrom
fix/clickhouse-eraser-user
Aug 5, 2026
Merged

feat(security): erase ClickHouse data as a dedicated user, not the superuser#66
marselsel merged 2 commits into
mainfrom
fix/clickhouse-eraser-user

Conversation

@marselsel

Copy link
Copy Markdown
Collaborator

Closes the largest remaining gap from the security review.

The deletion routes ran ALTER TABLE events DELETE through getMutatingClickHouseClient(), which keeps the CLICKHOUSE_URL user — the unrestricted default. The dashboard therefore held a full-rights ClickHouse credential in process.env for its whole lifetime, and the first deletion opened a superuser connection. Any RCE, SSRF-to-localhost or env dump yielded DDL on the analytics store, up to DROP TABLE events.

The new identity

CH migration 0012 adds yavio_eraser with exactly one capability:

GRANT ALTER DELETE ON default.events TO yavio_eraser;

No SELECT, no INSERT, no DDL, nothing on any other table — it cannot read the rows it is allowed to remove.

Created IDENTIFIED WITH no_password so it fails closed. Migration 0007 created its siblings with the literal yavio_dev published in this repo — precisely the defect this week had to unwind. The password comes from CLICKHOUSE_ERASER_PASSWORD, applied by migrate-clickhouse.ts alongside the other two, so it never lands in a migration file or its history.

Falls back to the CLICKHOUSE_URL user when unset, so a deployment that hasn't run 0012 keeps erasing rather than silently failing — the exact failure mode that shipped in #58.

Also fixes the test-hygiene gap the review flagged: the suite now saves/restores every CLICKHOUSE_* variable, not just CLICKHOUSE_URL. A repo .env previously turned a real assertion into an env-dependent flake.

Verification

510 dashboard tests (10 in this suite, covering both new branches), compose validates, setup-env.sh generates the password, typecheck and biome clean.

⚠️ Rollout order — do not merge-and-forget

Not deployed. Production must be rolled out in this order, mirroring the yavio_api and per-user ClickHouse rollouts:

  1. Add CLICKHOUSE_ERASER_PASSWORD to the VM .env (generate on the VM; never print it)
  2. Sync the VM docker-compose.yml from main — the VM copy is a deployed copy, not a checkout
  3. Run migrations — creates yavio_eraser and applies the password
  4. Verify yavio_eraser can authenticate before switching the dashboard
  5. Then recreate the dashboard

Step 4 is what caught the missing CONNECT grant during the yavio_api rollout and aborted with production untouched.

…peruser

The deletion routes ran ALTER TABLE events DELETE through
getMutatingClickHouseClient(), which keeps the user from CLICKHOUSE_URL — the
unrestricted `default`. So the dashboard held a full-rights ClickHouse
credential in its environment for the whole process lifetime, and the first
account/workspace/project deletion opened a superuser connection. Any RCE,
SSRF-to-localhost or env dump in the Next.js process yielded DDL on the
analytics store, up to DROP TABLE events. That was the largest remaining gap
from the security review, and the follow-up lib/clickhouse.ts already recorded.

CH migration 0012 adds `yavio_eraser` with exactly one capability:
GRANT ALTER DELETE ON default.events. No SELECT, no INSERT, no DDL, nothing on
any other table — it cannot read the rows it is allowed to remove.

The user is created with `IDENTIFIED WITH no_password`, so it fails closed.
Migration 0007 created its siblings with the literal 'yavio_dev', published in
this public repository, which is precisely the defect this week had to unwind.
The password comes from CLICKHOUSE_ERASER_PASSWORD and is applied by
migrate-clickhouse.ts alongside the ingest and dashboard users, so it never
appears in a migration file or the migration history.

The client FALLS BACK to the CLICKHOUSE_URL user when the password is unset, so
a deployment that has not yet run 0012 keeps erasing rather than silently
failing — which is the exact failure mode this file exists to prevent, and the
one that shipped in #58.

Also fixes the test-hygiene gap the review flagged: the suite now saves and
restores every CLICKHOUSE_* variable it touches, instead of only CLICKHOUSE_URL.
A repo .env setting CLICKHOUSE_DASHBOARD_PASSWORD previously turned a real
assertion into an environment-dependent flake.

Ten dashboard tests, including both new branches. 508 dashboard tests pass;
compose validates; setup-env.sh generates the new password; typecheck and biome
clean.

NOT YET DEPLOYED — see the rollout note in the PR. Migrations must run and the
password must be verified BEFORE the dashboard is switched, exactly as the
yavio_api and per-user ClickHouse rollouts did.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Coverage Report for ingest

Status Category Percentage Covered / Total
🔵 Lines 90.38% 940 / 1040
🔵 Statements 90.38% 940 / 1040
🔵 Functions 98.43% 63 / 64
🔵 Branches 93.35% 281 / 301
File CoverageNo changed files found.
Generated in workflow #189 for commit 421fabe by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Coverage Report for dashboard

Status Category Percentage Covered / Total
🔵 Lines 84.85% 2785 / 3282
🔵 Statements 84.85% 2785 / 3282
🔵 Functions 89.06% 171 / 192
🔵 Branches 89.62% 674 / 752
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/dashboard/lib/clickhouse.ts 100% 90% 100% 100%
Generated in workflow #189 for commit 421fabe by the Vitest Coverage Report Action

The census asserts the exact set of applied versions, so adding
0012_eraser_user.sql made it fail with 12 vs 11 — which is the check working as
intended rather than a flake. An exact-set assertion is deliberately noisier
than a count: it catches a migration that silently fails to apply.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Coverage Report for db

Status Category Percentage Covered / Total
🔵 Lines 100% 322 / 322
🔵 Statements 100% 322 / 322
🔵 Functions 100% 21 / 21
🔵 Branches 100% 43 / 43
File CoverageNo changed files found.
Generated in workflow #189 for commit 421fabe by the Vitest Coverage Report Action

@marselsel
marselsel merged commit 0adc879 into main Aug 5, 2026
34 of 36 checks passed
@marselsel
marselsel deleted the fix/clickhouse-eraser-user branch August 5, 2026 13:57
marselsel added a commit that referenced this pull request Aug 6, 2026
…hind (#69)

Three residual findings from reviewing #42#66, all the same shape: a
credential that stays published or absent when the operator takes a path the
scripts do not cover.

yavio_app kept the published 'yavio_dev'. 0001 creates the role with that
literal under an IF NOT EXISTS guard; migrate.ts repairs it from
POSTGRES_APP_PASSWORD, and setup-env.sh generates one — but the compose
`migrate` service never passed the variable, and compose-injected environment
is not inherited from .env. So a deployment migrating through compose kept a
role with a password anyone can read on GitHub and DML on all 14 tables, even
for an operator who ran setup-env.sh and followed every documented step. RLS is
no barrier: its policies key on current_setting('app.current_user_id'), which
the connecting session sets for itself, and verification_tokens,
login_attempts and stripe_webhook_events have no RLS at all.

The two MOST privileged accounts still fell back to 'yavio_dev'. #57 made
POSTGRES_API_PASSWORD fail closed with `:?` but left `:-yavio_dev` on
POSTGRES_SERVICE_PASSWORD — the Postgres SUPERUSER, which can COPY ... FROM
PROGRAM — and on CLICKHOUSE_PASSWORD, which is unrestricted and carries
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1. Both now use the same `:?` form, and
.env.example ships them blank rather than pre-filled. Compose refuses to start
instead of substituting; a startup error costs a minute, a published default is
a credential in a public repo.

setup-env.sh put every secret on a command line. `sed -i "s|^${key}=.*|..."`
makes the value part of argv, which is world-readable via ps and
/proc/<pid>/cmdline while /proc/<pid>/environ is owner-only — so all seven
datastore passwords and four app secrets were exposed to any local user, and
the chmod 600 two lines above bought nothing (CWE-214). Now the value goes
through the environment into awk. That also drops the BSD/GNU `sed -i` split
and the escaping question: awk prints the value literally, so a `|`, `&` or
backslash from a future generator cannot break the substitution or inject a
line. The temp file is created under umask 077 because mv keeps its mode.

Also: docker-compose.test.yml published a password-`test` Postgres and
ClickHouse on 0.0.0.0 from every developer's machine — the dev compose file has
bound to loopback since #42. And the self-hosting docs still advertised
'yavio_dev' as the default for five variables, omitted POSTGRES_API_PASSWORD
and CLICKHOUSE_ERASER_PASSWORD entirely, and told readers to change defaults
that no longer exist.

Verified rather than assumed. The first attempt at proving the yavio_app
rotation connected via 127.0.0.1 from inside the container, where pg_hba says
`host all all 127.0.0.1/32 trust` — both the old and new passwords
"authenticated" because no password was being checked at all. Re-run over a
scram-checked connection from the host: 'yavio_dev' REJECTED (28P01), rotated
password AUTHENTICATED. Compose fails closed with no .env and validates with
the variables set; setup-env.sh produces a 0600 .env, leaves no temp file, and
writes no placeholder into any value.
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.

1 participant