Skip to content

fix(security): yavio_eraser authenticated with no credential at all - #68

Merged
marselsel merged 2 commits into
mainfrom
fix/clickhouse-eraser-fail-closed
Aug 6, 2026
Merged

fix(security): yavio_eraser authenticated with no credential at all#68
marselsel merged 2 commits into
mainfrom
fix/clickhouse-eraser-fail-closed

Conversation

@marselsel

Copy link
Copy Markdown
Collaborator

Found by the security review of the #42#66 hardening series. This is a defect in my own PR #66.

What was wrong

Migration 0012 created the erasure user with IDENTIFIED WITH no_password, and its comment said "A user with no password cannot authenticate, so this fails closed." That is true of Postgres, which is where the pattern came from. In ClickHouse it is the exact opposite: no_password is an authentication method meaning no credential is required. checkBasicAuthentication returns success for AuthenticationType::NO_PASSWORD with the upstream comment "N.B. even if the password is not empty!" — so a wrong password authenticates too. CREATE USER with no HOST clause also defaults to HOST ANY.

I verified this against a real ClickHouse rather than arguing it from the docs:

auth_type now: no_password
  with a deliberately wrong password -> AUTHENTICATED
  with no password at all            -> AUTHENTICATED

That account holds ALTER DELETE ON default.events — every tenant's events, in one statement, with no credential.

Who was affected

Not this deployment, and not fresh installs.

  • Fresh installs are finesetup-env.sh generates CLICKHOUSE_ERASER_PASSWORD before the first migrate.
  • Upgrades were not. applyUserPasswords() skips silently when the variable is unset, compose defaults it to empty, and .env.example ships it blank. An operator who ran git pull && docker compose up -d with a pre-existing .env got a permanently passwordless, delete-capable account. Nothing surfaced it: the dashboard falls back to the superuser when the variable is unset, so erasure kept working.
  • Our production is fine — its eraser rejected a deliberately wrong password with error 516 during the feat(security): erase ClickHouse data as a dedicated user, not the superuser #66 rollout, which is precisely the test that distinguishes no_password (accepts anything) from sha256_password.
  • Not internet-reachable in any case; ClickHouse is bound to 127.0.0.1. Exploitation needed local host access or a compromised container on the backend network. Integrity only — the account genuinely cannot read what it erases.

The fix

  • 0013_eraser_fail_closed.sql repairs deployments that already ran the old 0012, using sha256_hash with a digest whose preimage was never generated. The account keeps its grant and stops being reachable. Publishing that value leaks nothing, and a test proves it — authenticating with the hash itself is rejected.
  • 0012 is amended so fresh installs never pass through the open state, and its comment no longer asserts the opposite of what the database does.
  • migrate-clickhouse.ts refuses to finish while any managed user is on no_password. Worth stating plainly: the rollout check written for feat(security): erase ClickHouse data as a dedicated user, not the superuser #66 asked "can yavio_eraser authenticate?" — a question a no_password account answers yes to for any credential, including the one you believe you just set. That check could not fail. Asserting auth_type is the same check with a failure mode.
  • It also warns when CLICKHOUSE_ERASER_PASSWORD is unset, because the dashboard then erases as the CLICKHOUSE_URL superuser — the arrangement feat(security): erase ClickHouse data as a dedicated user, not the superuser #66 existed to end.

Verification

All against a real ClickHouse, not mocks.

The new tests fail on the old code — reverting both migrations gives expected [ 'yavio_eraser' ] to deeply equal [].

The upgrade path, simulated by leaving the container on the old 0012 with 0012 recorded, then running the real migrator:

[migrate:clickhouse] Applying 0013_eraser_fail_closed.sql…
[migrate:clickhouse] CLICKHOUSE_ERASER_PASSWORD is not set — yavio_eraser is left
  unauthenticatable and the dashboard will erase as the CLICKHOUSE_URL superuser.

  eraser auth_type: sha256_password
  grant preserved:  ALTER DELETE on default.events
  no password        -> Code: 516. Authentication failed
  wrong password     -> Code: 516. Authentication failed
  the published hash -> Code: 516. Authentication failed
  ALTER TABLE ... DELETE as eraser -> Code: 516. Authentication failed

The configured path still works — with the password set: authenticates, can delete, and SELECT is still Code: 497. Not enough privilege.

87/87 packages/db tests pass.

Deploying

No .env change is needed here — the VM already has CLICKHOUSE_ERASER_PASSWORD, so 0013 resets the credential and applyUserPasswords() restores it within the same migrate run, before the dashboard is touched.

A follow-up PR covers the rest of the review: POSTGRES_APP_PASSWORD never reaching the containerised migrator, the two :-yavio_dev fallbacks on the most privileged accounts, and two low-severity items.

Migration 0012 created the erasure user with `IDENTIFIED WITH no_password`
and the comment "a user with no password cannot authenticate, so this fails
closed". That is Postgres behaviour. In ClickHouse `no_password` is an
authentication method meaning no credential is REQUIRED — checkBasicAuthentication
returns success for AuthenticationType::NO_PASSWORD, carrying the upstream
comment "N.B. even if the password is not empty!". It failed open.

Verified against a real ClickHouse rather than argued from the docs: a
no_password yavio_eraser authenticates with an empty password AND with a
deliberately wrong one, and then executes `ALTER TABLE default.events DELETE`.
The account holds ALTER DELETE on every tenant's events, so any process that
could open a socket to ClickHouse could erase the analytics store without a
credential. It cannot read, so this was integrity only, and the datastores are
loopback-bound — but it defeated the control the eraser user exists to be.

The real password is applied out of band by applyUserPasswords(), which skips
silently when CLICKHOUSE_ERASER_PASSWORD is unset. compose defaults that to
empty and .env.example ships it blank, so fresh installs via setup-env.sh were
fine and any deployment that upgraded without adding the variable was not.

- 0013 repairs deployments that already ran 0012, via sha256_hash with a digest
  whose preimage was never generated. Publishing that value leaks nothing and
  the test proves it: authenticating with the hash itself is rejected.
- 0012 is amended so fresh installs never pass through the open state, and its
  comment no longer asserts the opposite of what the database does.
- migrate-clickhouse.ts now refuses to finish while any managed user is on
  no_password. The 0012 rollout check asked "can yavio_eraser authenticate?",
  which a no_password account answers yes to for any credential — a check that
  cannot fail. Asserting auth_type is that check with a failure mode.
- It also warns when CLICKHOUSE_ERASER_PASSWORD is unset, because the dashboard
  then erases as the CLICKHOUSE_URL superuser, which is what 0012 set out to end.

Tests assert no managed user is left on no_password, that yavio_eraser exists
so that assertion is not vacuous, and that its grants are exactly ALTER DELETE
on default.events. Reverting the migrations fails them with
`expected [ 'yavio_eraser' ] to deeply equal []`.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Coverage Report for db

Status Category Percentage Covered / Total
🔵 Lines 98.16% 374 / 381
🔵 Statements 98.16% 374 / 381
🔵 Functions 100% 25 / 25
🔵 Branches 96.49% 55 / 57
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/db/src/clickhouse-credentials.ts 85.41% 90.9% 100% 85.41% 90-96
packages/db/src/migrate-clickhouse-helpers.ts 100% 90.9% 100% 100%
Generated in workflow #195 for commit 9d63d1b by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 6, 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.37% 282 / 302
File CoverageNo changed files found.
Generated in workflow #195 for commit 9d63d1b by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Coverage Report for dashboard

Status Category Percentage Covered / Total
🔵 Lines 85% 2818 / 3315
🔵 Statements 85% 2818 / 3315
🔵 Functions 89.11% 172 / 193
🔵 Branches 89.63% 683 / 762
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/dashboard/lib/clickhouse.ts 100% 90% 100% 100%
Generated in workflow #195 for commit 9d63d1b by the Vitest Coverage Report Action

Addresses the review of this PR. The first version shipped the repair as
migration 0013: an unconditional `ALTER USER yavio_eraser IDENTIFIED WITH
sha256_hash`, applied to every existing deployment. That is a destructive reset
of a WORKING credential, put back only by applyUserPasswords in the same
process — so any environment drift between the migrator and the dashboard would
leave the eraser unreachable. And a broken eraser is silent: all three deletion
routes catch the ClickHouse failure, console.error it, and still return 200
after the Postgres rows are gone. The old no_password bug was masking exactly
that class of mismatch, because it accepted any credential. Fixing a fail-open
auth hole by introducing an undetected data-retention failure is not a fix.

0013 is gone. repairPasswordlessUsers() in the new clickhouse-credentials.ts
repairs ONLY an account that is actually passwordless, so a user already on a
real password is never touched. Proven live: apply a password, re-run the
migrator with no password in its environment, credential survives.

Also from the review:
- assertNoPasswordlessUsers could pass vacuously. `SELECT ... FROM system.users`
  is access-filtered, not error-raising, so a migrator without SHOW USERS sees
  zero rows and the guard reported success — the same "check that cannot fail"
  its own docstring condemns. It now refuses when it was told users exist and
  inspected none.
- Neither the guard nor the repair had any test. helpers/clickhouse.ts
  runMigrations() is a reimplementation of main() that skips both, and the tests
  duplicated the auth_type predicate instead of calling it, so they proved the
  database state and nothing about the shipped code. Tests now call the real
  functions, and a new describe block builds the pre-fix state and exercises the
  repair — CI runs on fresh containers where the amended 0012 alone satisfied
  every assertion, so deleting the repair used to leave the suite green.
- dropAll() left the three users behind. They are server-level objects that
  survive a table drop, so against a persistent ClickHouse the credential tests
  asserted on accounts created weeks ago.
- The grants test saw only direct grants: a granted role would have carried
  SELECT in invisibly. Now asserts system.role_grants empty and grant_option 0.
- MANAGED_USERS was duplicated in four places and the eraser had a hardcoded
  string comparison. Single tuple in migrate-clickhouse-helpers.ts with a
  warnWhenUnset flag, so the policy lives next to the user.
- The unset-password warning asserted the account was "left unauthenticatable",
  which the code never checked and which is false when a password was applied on
  an earlier run. It now states only the certain consequence: the dashboard
  falls back to the CLICKHOUSE_URL superuser.

One of the new tests was itself vacuous on the first run — asserting
`res.status !== 516` when ClickHouse answers a rejected credential with HTTP 403
and puts `Code: 516` in the BODY, so the status could never be 516. Now reads
the body. Verified with teeth: neutering repairPasswordlessUsers fails 2 tests.
93/93 packages/db tests pass.
@marselsel
marselsel merged commit 172ee19 into main Aug 6, 2026
12 checks passed
@marselsel
marselsel deleted the fix/clickhouse-eraser-fail-closed branch August 6, 2026 10:12
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