Enforce access control for MySQL COM_FIELD_LIST and COM_INIT_DB by groeneai · Pull Request #108508 · ClickHouse/ClickHouse · GitHub
Skip to content

Enforce access control for MySQL COM_FIELD_LIST and COM_INIT_DB#108508

Open
groeneai wants to merge 4 commits into
ClickHouse:masterfrom
groeneai:mysql-handler-metadata-access-control
Open

Enforce access control for MySQL COM_FIELD_LIST and COM_INIT_DB#108508
groeneai wants to merge 4 commits into
ClickHouse:masterfrom
groeneai:mysql-handler-metadata-access-control

Conversation

@groeneai

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

The MySQL wire protocol commands COM_FIELD_LIST (mysql_list_fields) and COM_INIT_DB (USE database) now enforce the same access control as their SQL equivalents (SHOW COLUMNS/DESCRIBE and USE). Previously they could disclose column names of tables the user only had partial column grants on, and switch the current database without the SHOW DATABASES privilege.

Description

src/Server/MySQLHandler.cpp executes two metadata commands on the MySQL protocol that bypassed access control that the equivalent SQL statements enforce (comQuery routes through executeQuery, where ACL is checked; these did not):

  1. comFieldList (COM_FIELD_LIST, opcode 0x04) read table metadata via DatabaseCatalog::getTable(...) and emitted every column name with no check. The SQL equivalent DESCRIBE/SHOW COLUMNS is access-checked in InterpreterDescribeQuery via checkAccess(SHOW_COLUMNS, table_id).
  2. comInitDB (COM_INIT_DB, opcode 0x02) called setCurrentDatabase(database) with no check. The SQL equivalent USE database is access-checked in InterpreterUseQuery via checkAccess(SHOW_DATABASES, new_database).
  3. run() set the current database from the client handshake response (setCurrentDatabase(handshake_response.database)) with the same missing SHOW_DATABASES check.

A user could therefore call mysql_list_fields("employees") and learn all column names of a table they only hold a column-level grant on (e.g. GRANT SELECT(id, name)), or send COM_INIT_DB system with zero grants and then COM_FIELD_LIST users to read the system.users column names. The disclosure runs under the caller's own session (no privilege escalation), and only column names leak (the column type is hardcoded to MYSQL_TYPE_STRING, no row data).

The fix adds the same checks the SQL statements perform:

  • comInitDB and the handshake path: checkAccess(SHOW_DATABASES, database) before setCurrentDatabase.
  • comFieldList: checkAccess(SHOW_COLUMNS, database, table) before getTable(), so the command does not become a table-existence oracle.

A denied check throws ACCESS_DENIED, which the handler's existing catch block turns into a MySQL error packet (no crash).

Design note for reviewers. The table-level checkAccess(SHOW_COLUMNS, db, table) matches DESCRIBE and fails closed: a partial-column-grant user (GRANT SELECT(id, name)) is denied entirely, whereas SHOW COLUMNS over COM_QUERY returns the granted subset [id, name]. Exact SHOW COLUMNS parity would require per-column filtering (mirroring StorageSystemColumns: isGranted(SHOW_COLUMNS, db, table, column) per column), but that must enumerate the columns first, which reintroduces a table-existence oracle for zero-grant users. This PR uses the table-level check (simplest, no oracle; mysql_list_fields is deprecated since MySQL 8.0). Happy to switch to per-column filtering if preferred.

Test. tests/integration/test_mysql_protocol/test.py::test_mysql_metadata_commands_access_control drives the raw COM_FIELD_LIST/COM_INIT_DB opcodes via pymysql for a user with only SELECT(id, name), asserts both are rejected with ACCESS_DENIED (SHOW COLUMNS/SHOW DATABASES), and asserts a user granted SHOW COLUMNS still receives all columns.

The MySQL wire protocol handler executes two metadata commands that skipped
the access control enforced by their SQL equivalents:

- COM_FIELD_LIST (mysql_list_fields) emitted every column name of a table
  with no check, while DESCRIBE / SHOW COLUMNS require SHOW_COLUMNS.
- COM_INIT_DB (USE database) set the current database with no check, while
  the USE statement requires SHOW_DATABASES. The same omission existed for
  the database taken from the client handshake response.

A user could therefore read column names of tables they only hold partial
column grants on, and switch into databases they have no grants on, over the
MySQL protocol. Add the same checks the SQL statements perform. The
SHOW_COLUMNS check is done before getTable() so the command does not become a
table-existence oracle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @vitlibar @yakov-olkhovskiy — could you review this? It adds the missing access-control checks to the MySQL wire protocol metadata commands: COM_FIELD_LIST now requires SHOW_COLUMNS (like DESCRIBE) and COM_INIT_DB / handshake-time database selection now require SHOW_DATABASES (like USE), matching what the equivalent SQL statements already enforce.

@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Jun 25, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [88795dc]

Summary:

job_name test_name status info comment
AST fuzzer (arm_asan_ubsan) FAIL
Received signal 15 (STID: None) FAIL cidb

AI Review

Summary

This PR adds access checks to the MySQL protocol metadata commands: COM_INIT_DB now checks SHOW_DATABASES before changing the current database, and COM_FIELD_LIST now checks table-level SHOW_COLUMNS before reading table metadata. The production change matches the explicit USE and DESCRIBE paths and the added integration tests cover denied partial-grant access, allowed SHOW_COLUMNS access, denied COM_INIT_DB, and the intentionally unchecked handshake database path. I did not find a code issue that needs an inline review comment.

PR Metadata
  • 💡 The selected changelog category is Bug Fix (user-visible misbehavior in an official stable release), but this is an RBAC/access-control fix and the template has a dedicated category for that. Use:
    Critical Bug Fix (crash, data loss, RBAC)
  • 💡 The changelog entry is specific and user-readable. The longer PR description is stale after the last commit: it still says the handshake path is fixed by adding SHOW_DATABASES before setCurrentDatabase, while the current code intentionally leaves the handshake database unchecked for parity with other protocols. Please remove or rewrite those handshake-check claims.
Missing context / blind spots
  • ⚠️ I did not run the integration test locally. The fetched PR CI report had no failed jobs at review time, but some GitHub checks were still in progress.
Final Verdict

Status: ✅ Approve

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jun 25, 2026
Comment thread src/Server/MySQLHandler.cpp Outdated
The handshake-time SHOW_DATABASES check threw on denial, but the
surrounding setup try/catch sent an ERR packet and then fell through to
the normal handshake OK packet and the command loop. A client that
ignores the error therefore still proceeded on an authenticated session
with an unauthorized initial database (two terminal handshake packets,
ERR followed by OK).

Return after sending the setup error so any session-setup failure
(including the access denial) cleanly fails the handshake: no OK packet,
no command loop. sendPacket flushes by default, so the ERR has already
reached the client before run() returns and SCOPE_EXIT closes the
connection.

comInitDB and comFieldList denials are unaffected: they throw inside the
command loop, whose catch sends a per-command ERR and keeps the
connection open, matching real MySQL behaviour for a denied USE/DESCRIBE.

Extend the regression test with a raw-socket client that drives the
handshake with a denied initial database and asserts the server does not
send an OK or leave a usable session after the error, plus a sanity check
that an allowed database still completes the handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed in a67121e. The handshake-setup catch now returns after sending the ERRPacket, so a denied initial database fails the handshake cleanly: no subsequent OK packet, no command loop. sendPacket flushes by default, so the error reaches the client before run() returns and SCOPE_EXIT closes the connection. comInitDB/comFieldList denials are unaffected (they throw in the command loop, whose catch sends a per-command ERR and keeps the connection open, matching MySQL's USE/DESCRIBE).

Added a regression that drives the handshake over a raw socket with database=system as the GRANT SELECT(id,name)-only user and asserts the server sends no OK and leaves no usable session after the error (plus an allowed-database sanity check). Verified both directions against a local build: on the previous binary the denied handshake yielded ERR, OK, SESSION_USABLE (test fails); with the fix it yields ERR, <connection closed> (test passes), allowed path still OK, SESSION_USABLE.

Session id: cron:clickhouse-worker-slot-6:20260625-164900

The previous commit added a SHOW_DATABASES check on the database selected in
the MySQL handshake (CLIENT_CONNECT_WITH_DB) and returned from run() on denial.
That made the MySQL protocol the only one that access-checks the connection-time
database: native TCP (TCPHandler), HTTP (HTTPHandler) and PostgreSQL
(PostgreSQLHandler) all setCurrentDatabase() for the connect-time database with
no SHOW_DATABASES check. Only the explicit USE statement / COM_INIT_DB is checked
(InterpreterUseQuery). As a result a user with no grants could no longer connect
over MySQL to the default database, which broke test_session_log::test_mysql_session
(a freshly created, ungranted user connecting to default and running SELECT 1).

Revert the handshake check and the early return so the handshake database matches
every other protocol. This does not reintroduce a disclosure: COM_FIELD_LIST is
access-checked against the current database regardless of how it was set, so
connecting to e.g. system in the handshake still cannot list a system table the
user lacks SHOW COLUMNS on. The COM_INIT_DB (SHOW_DATABASES) and COM_FIELD_LIST
(SHOW_COLUMNS) checks are kept.

Tests:
- test_mysql_metadata_commands_access_control: the default user in this test's
  users.xml has password 123, so the server-side setup queries now pass
  settings={"password": "123"} (they failed native auth before).
- Replace test_mysql_handshake_database_access_control with
  test_mysql_handshake_database_does_not_leak_columns: a low-privilege user can
  open a handshake to system (parity with other protocols), but COM_FIELD_LIST on
  a system table is still rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Pushed adfe4b49de9. After the fall-through fix, the handshake SHOW_DATABASES check turned out to be wrong in a different way and is now removed (the third site is reverted; COM_INIT_DB and COM_FIELD_LIST checks stay).

The handshake initial database (CLIENT_CONNECT_WITH_DB) is the connection-time database, equivalent to native --database / HTTP database= / the PostgreSQL startup database, none of which are SHOW_DATABASES-checked. Only the explicit USE statement is (InterpreterUseQuery), and that maps to COM_INIT_DB, which is still checked. Gating the handshake made MySQL the only protocol where an ungranted user could not even connect to default, which broke test_session_log::test_mysql_session (a no-grant user connecting to default and running SELECT 1).

Removing the handshake check does not reintroduce the disclosure: COM_FIELD_LIST is checked against the current database however it was set, so connecting to system in the handshake still cannot list a system table the user lacks SHOW COLUMNS on.

The handshake regression test is replaced accordingly: test_mysql_handshake_database_does_not_leak_columns opens a raw-socket handshake to system as a low-privilege user (now succeeds), then asserts COM_FIELD_LIST users is still rejected. Both new tests' server-side setup also now pass the default user's password.

Local validation: full test_mysql_protocol (21 passed) and test_session_log (4 passed) suites green.

Session id: cron:clickhouse-worker-slot-2:20260625-212600

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — adfe4b4

Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
AST fuzzer (amd_debug) / Logical error: Not-ready Set ... in (STID 0250-41a5) chronic trunk bug (14 master + ~20 unrelated PRs/30d; unrelated to this PR's MySQLHandler.cpp change) #102192 (external, open) — @serxa/@alexey-milovidov buildOrderedSetInplace fix
Stateless tests (amd_tsan, s3 storage, parallel, 2/2) / ThreadSanitizer: data race (STID 4071-3348) + Server died trunk data race in QueryStatus::releaseWorkloadResources (ProcessList.cpp:532); unrelated to this PR #108391 (merged 2026-06-25 23:49Z) — merged master into this branch @ 88795dc to pick it up

Neither failure is in this PR's changed files (src/Server/MySQLHandler.cpp, tests/integration/test_mysql_protocol/test.py). The TSAN race fix (#108391) merged after this build was cut, so master was merged into the branch (HEAD now 88795dc6) so CI re-runs with the fix. The Not-ready Set finding is tracked by the still-open external fix #102192.

Session id: cron:our-pr-ci-monitor:20260626-023000

@clickhouse-gh

clickhouse-gh Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.40% 85.40% +0.00%
Functions 92.60% 92.60% +0.00%
Branches 77.60% 77.60% +0.00%

Changed lines: Changed C/C++ lines covered by tests: 13/13 (100.00%) | Lost baseline coverage: none · Uncovered code

Full report · Diff report

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — 88795dc

Re-validated after merging master (474a3c1, which contains the merged TSAN fix #108391) into this branch. The two failures from the prior ledger (adfe4b49) are resolved: Stateless tests (amd_tsan, s3) ThreadSanitizer data race STID 4071-3348 no longer fires (amd_tsan AST fuzzer + stateless now SUCCESS — #108391 is on-branch), and AST fuzzer (amd_debug) is SUCCESS (the chronic 0250-41a5 did not recur). One remaining non-PR-caused failure, owned:

Check / test Reason Owner / fixing PR
AST fuzzer (arm_asan_ubsan) / "Received signal 15" infra (SIGTERM job-termination — no stack/STID/query; not a ClickHouse crash; 3 unrelated PRs/7d, 0 master) a fix task is created (investigating at full effort — fixing-PR link to follow here)

PR diff is src/Server/MySQLHandler.cpp + tests/integration/test_mysql_protocol/test.py only; no causal link to the arm_asan_ubsan AST-fuzzer job being SIGTERM'd. Not PR-caused.

Session id: cron:our-pr-ci-monitor:20260626-070000

@groeneai

Copy link
Copy Markdown
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants