Skip to content

Fix unauthenticated TablesStatusRequest in interserver mode - #99854

Merged
tiandiwonder merged 17 commits into
ClickHouse:masterfrom
tiandiwonder:fix_unauthenticated_table_existence_request
Jul 15, 2026
Merged

Fix unauthenticated TablesStatusRequest in interserver mode#99854
tiandiwonder merged 17 commits into
ClickHouse:masterfrom
tiandiwonder:fix_unauthenticated_table_existence_request

Conversation

@tiandiwonder

@tiandiwonder tiandiwonder commented Mar 18, 2026

Copy link
Copy Markdown
Contributor
Before this fix, a TCP client could spoof an interserver connection
(user = `USER_INTERSERVER_MARKER`, empty password) and send a
`TablesStatusRequest` (packet type 5) before completing cluster-secret
authentication. The server processed it using a fake interserver context,
leaking table existence, replication status, and replication delay.

Root cause: `receivePacketsExpectQuery` handled `TablesStatusRequest`
without any authentication check. The cluster-secret SHA-256 validation
only runs inside `processQuery` when a `Query` packet is received.
The existing guard at lines 917-924 only fires on exceptions, but
`processTablesStatusRequest` succeeds without throwing.

Fix: add a pre-authentication check in the `TablesStatusRequest` branch.
The thrown `AUTHENTICATION_FAILED` exception is caught by the existing
unauthenticated-interserver guard, which silently closes the connection.

Test: integration test `test_interserver_tables_status_auth` — verifies
the connection is closed without leaking table information (old protocol
rejected by default; two-nodes wrong-secret case rejected on the new
protocol), and that a properly authenticated cluster keeps working.

Changelog category (leave one):

  • Critical Bug Fix (crash, data loss, RBAC)

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

Fix a security issue where an unauthenticated TCP client could probe table existence and replication status via the interserver port.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

Version info

  • Merged into: 26.7.1.975 (included in 26.7 and later)

@clickhouse-gh

clickhouse-gh Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [01797bc]

Summary:


AI Review

Summary

This PR closes the table-status disclosure itself by authenticating interserver TablesStatusRequest messages and binding the hash to the requested table set. However, the current head still deserializes the entire unauthenticated request body on the new-protocol path before checking that hash, so the pre-auth resource-exhaustion concern raised earlier is still present.

Findings

⚠️ Majors

  • src/Server/TCPHandler.cpp:1617 The new-protocol path still calls request.read(*in, client_tcp_protocol_version) before the hash check at src/Server/TCPHandler.cpp:1644, and TablesStatusRequest::read populates plain std::string table names at src/Interpreters/TablesStatus.cpp:100-102. A spoofed interserver client can therefore send any 32-byte hash plus a very large table list and still force pre-auth allocations / CPU before the connection is closed. Suggested fix: do not materialize the whole request before authentication; either compute the authenticated digest while streaming over a bounded representation or move to a scheme where the client sends a size-limited digestable prefix that can be verified before TablesStatusRequest::read.
Final Verdict

❌ Changes requested

@clickhouse-gh clickhouse-gh Bot added pr-critical-bugfix pr-must-backport Pull request should be backported intentionally. Use this label with great care! labels Mar 18, 2026
Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.sh Outdated
tiandiwonder and others added 2 commits May 5, 2026 05:17
The test uses `CLICKHOUSE_DATABASE` for table isolation and operates at
the TCP connection level; parallel execution poses no hazard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rschu1ze

rschu1ze commented May 5, 2026

Copy link
Copy Markdown
Member

@tiandiwonder The changelog entry is for end users and database administorators but these people won't be able to decipher what Fix unauthenticated TablesStatusRequest in interserver mode. means. The text sounds concerning in terms of security but it also lacks details. Please rework, thanks.


python3 "$CURDIR"/04036_interserver_tables_status_auth.python

$CLICKHOUSE_CLIENT -q "DROP TABLE IF EXISTS t_interserver_auth_check"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IF EXISTS isn't needed

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.

This stateless test was later removed in favor of the integration test test_interserver_tables_status_auth (needed for Bugfix validation and the two-nodes wrong-secret case), so this no longer applies.

Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
read_ws(sock); read_ws(sock); read_vu(sock)
sock.sendall(tsr)
data = sock.recv(4096)
print("FAIL: server returned data" if data else "OK: connection closed without leaking table status")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder why there can be two different success paths (l. 56 and l. 58). Should the server not respond in a deterministic way?

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.

The server behavior is deterministic — it always closes the connection without sending data.
The two paths handle OS-level TCP delivery differences: an empty recv() result indicates a graceful close (FIN), while ConnectionResetError/EOFError indicates an abrupt close (RST). Both mean no data was leaked. Added a comment explaining this.

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.

This raw-socket stateless test was later replaced by the integration test test_interserver_tables_status_auth, which asserts the same property (connection closed with no data leaked) through the client library instead of raw recv, so the two-path handling is gone.

Comment thread src/Server/TCPHandler.cpp Outdated
@rschu1ze rschu1ze self-assigned this May 5, 2026
@tiandiwonder

This comment was marked as resolved.

tiandiwonder and others added 2 commits May 7, 2026 01:55
- Move `!is_interserver_authenticated` check into `processTablesStatusRequest()`
  inside the `if (is_interserver_mode)` block, as suggested by reviewer
- Remove unnecessary `IF NOT EXISTS` / `IF EXISTS` from CREATE/DROP TABLE in test
- Rename `vu`/`ws`/`read_vu`/`read_ws` to `varbyte`/`varstring`/`read_varbyte`/`read_varstring`
- Replace terse AI-generated comments with protocol-level documentation explaining
  the Hello packet layout, TablesStatusRequest layout, and expected outcome
- Rewrite chained server-Hello reads as one call per line with field annotations
- Add comment explaining why two TCP close paths (graceful FIN vs RST) both count as success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread src/Server/TCPHandler.cpp
ContextPtr context_to_resolve_table_names;
if (is_interserver_mode)
{
if (!is_interserver_authenticated)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TablesStatusRequest is parsed before the interserver-auth check, so an unauthenticated client can still force the server to decode and allocate request payloads before being rejected.

Since this branch closes the connection on auth failure anyway, please move the is_interserver_authenticated check before request.read(*in, client_tcp_protocol_version) to fail fast and avoid pre-auth parsing work.

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.

Fixed in 4155290 — the secret hash is now sent (client) and validated (server) before TablesStatusRequest::read, so an unauthenticated peer can no longer force the server to decode the request body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is still present in the current head. In the new-protocol branch, processTablesStatusRequest still deserializes the full body at src/Server/TCPHandler.cpp:1617 before comparing the hash at src/Server/TCPHandler.cpp:1644, and TablesStatusRequest::read still fills plain std::string table names at src/Interpreters/TablesStatus.cpp:100-102.

That means a spoofed interserver client can announce revision 54487, send any 32-byte hash, and still force pre-auth allocations / CPU for an arbitrarily large table list before the connection is dropped. If we want the hash bound to the body without reopening that exposure, the digest needs to be computed while streaming or from an explicitly size-limited representation, not by materializing the whole unauthenticated request first.

Comment thread tests/queries/0_stateless/04036_interserver_tables_status_auth.python Outdated
@tiandiwonder
tiandiwonder requested a review from rschu1ze May 7, 2026 02:05
@tiandiwonder tiandiwonder added the can be tested Allows running workflows for external contributors label Jun 5, 2026
Comment thread src/Server/TCPHandler.cpp
`processUnexpectedTablesStatusRequest` read the request body directly, so
on an interserver connection with protocol revision >= 54486 an
out-of-place `TablesStatusRequest` would decode the 32-byte hash prefix as
the request body and fail with unrelated parse errors instead of
`UNEXPECTED_PACKET_FROM_CLIENT`. Consume the same prefix as
`processTablesStatusRequest`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tiandiwonder

tiandiwonder commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@rschu1ze > Can you please check if this PR is a duplicate of #99675 ?

Not a duplicate — the two PRs address complementary halves of #99512.
#99675 hardens the Hello phase only: it rejects the USER_INTERSERVER_MARKER when the named cluster is unknown or has no <secret> configured, but its own description explicitly leaves open the case where the cluster does have a secret.

This PR is exactly that tracked-separately part.

Comment thread src/Client/Connection.cpp
tiandiwonder and others added 2 commits July 8, 2026 10:17
# Conflicts:
#	src/Core/ProtocolDefines.h
The cluster-secret hash proving a peer may issue a `TablesStatusRequest` covered
only `salt + nonce + cluster_secret + "TablesStatusRequest"`, not the requested
table list, so a relayed hash could be reused to read the status of arbitrary
tables. Fold an order-independent, length-prefixed digest of `request.tables`
into the hash on both client and server, mirroring the per-query secret hash
that `processQuery` computes over the query text.

The hash precedes the body on the wire, so the server deserializes the body to
recompute the digest before validating (as `processQuery` reads the query before
validating), using `StringWithMemoryTracking` for the hash duplicate. Table
resolution still happens only after validation, and an old-protocol
unauthenticated request rejected by `interserver_tables_status_require_auth` is
refused before its body is read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 80.00% 85.80% +5.80%
Functions 92.20% 92.70% +0.50%
Branches 72.00% 78.00% +6.00%

Changed lines: Changed C/C++ lines covered: 105/111 (94.59%) · Uncovered code

Full report · Diff report

@tiandiwonder

tiandiwonder commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Backport decision: master-only

After analysis, this fix will not be backported to any release branch (OSS or Cloud). It stays on master only. (This supersedes the earlier note in this thread that suggested 26.6 could take it.)

Why it can't be cleanly backported

The fix authenticates the interserver TablesStatusRequest with a cluster-secret hash and gates it on a new global TCP protocol revision (DBMS_MIN_REVISION_WITH_INTERSERVER_SECRET_TABLES_STATUS = 54487, bumping DBMS_TCP_PROTOCOL_VERSION to 54487). It is an interserver-only path: ConnectionEstablishergetTablesStatus, i.e. the replica health-check a server runs while establishing a distributed-query connection, before the first secret-authenticated query.

  1. Protocol-version coupling. Advertising 54487 is a promise that the build implements every revision up to 54487; a peer trusts that number and will send those wire features. Release branches sit below 54487 and are missing intervening revisions, so advertising 54487 would falsely claim features they cannot parse, desyncing interserver connections with newer peers:

    • 25.8 (54479) is missing 54480–54486 (54480 OUT_OF_ORDER_BUCKETS and 54481 COMPRESSED_LOGS_PROFILE_EVENTS were deliberately reverted on this line).
    • 26.3/26.4/26.5 (54484) are missing 54485 CLIENT_AGENT_IN_CLIENT_INFO (a feature that adds a system.query_log column) and 54486 INTERNAL_QUERY_FLAG.
    • 26.6 (54485) is missing 54486 INTERNAL_QUERY_FLAG (Propagate the internal flag to secondary queries #108506 — an 18-file behavioral change that also edits the NativeFormat/NativeProtocol specs).
      Reusing a lower revision number instead collides with that revision's global meaning across versions.
  2. No revision-independent gating. Every addendum field is introduced behind its own global-revision gate and read in strict order, so adding a new negotiated capability field still needs a new global revision (same wall). Overloading an existing independent sub-version (e.g. DBMS_PARALLEL_REPLICAS_PROTOCOL_VERSION) collides cross-version.

  3. A fail-closed (no-wire) mitigation is not viable either. TablesStatusRequest is the pre-authentication distributed replica health-check — the same path the vulnerability abuses — so the server cannot distinguish a legitimate initiator from an attacker at that point without the hash. Refusing the request (server throws / closes the connection) makes ConnectionEstablisher mark replicas as failed connections, so distributed queries over a secret-configured cluster fail when the option is enabled. A graceful variant would require a coordinated client+server change that disables staleness/readonly-aware interserver routing — a functional regression — and still has rolling-upgrade coordination issues.

Mitigation for supported release branches

Supported release branches rely on network isolation of the interserver port. The vulnerability discloses only table existence / replication delay / readonly status to a party that can reach the interserver TCP port and present the interserver user marker — it reads no data and writes nothing — and the interserver port is normally not exposed outside the cluster.

The bot-created cherry-pick PRs for all release branches are closed with a pointer here.

@robot-ch-test-poll robot-ch-test-poll added the pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore label Jul 15, 2026
@EmeraldShift

Copy link
Copy Markdown
Contributor

Our organization attempted an upgrade from 26.6 to 26.7 and started seeing errors which we suspect are caused by the new rejection path introduced in this PR. This caused queries to fail and made us unable to upgrade the cluster in a rolling manner. Does that sound correct? If so, we believe this should be marked as a backward-incompatible change.

@tiandiwonder

tiandiwonder commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Does that sound correct?

Yes, that's correct.

Workaround: set interserver_tables_status_require_auth to false in the server config of the upgraded nodes, finish the upgrade, then remove the override. Once every node is on 26.7+, the hash is sent and validated and the setting is irrelevant.

we believe this should be marked as a backward-incompatible change

And yes — this should have been marked as a backward-incompatible change instead of being mentioned only in the setting's description. I'll add a changelog and upgrade-notes entry for it, and we'll revisit whether the default should be inverted so the hardening is opt-in until a cluster is fully upgraded.

zeekay pushed a commit to hanzoai/datastore that referenced this pull request Sep 11, 2026
…ion test

Follow-up to the interserver TablesStatusRequest authentication, addressing
review comments on ClickHouse#99854:

- Authenticate before decoding the request body. The secret hash is now sent
  (client) and validated (server) *before* `TablesStatusRequest::read`, so an
  unauthenticated peer can no longer force the server to decode an unauthenticated
  request.

- Default `interserver_tables_status_require_auth` to `true` (secure by default):
  old-protocol peers that send no hash are rejected unless an operator opts out
  for a mixed-version rolling upgrade. Documented the upgrade caveat in the
  setting description.

- Extend the integration test to also cover the new-protocol rejection path: two
  nodes configure the same cluster with different secrets, so a peer that signs
  the request with the wrong secret is rejected by hash validation during
  connection establishment (asserted via node_b's log), in addition to the
  old-protocol no-hash rejection.

Verified locally: authenticated path still succeeds with the hash sent first and
the setting defaulting on; old-protocol peer rejected by default.
zeekay pushed a commit to hanzoai/datastore that referenced this pull request Sep 11, 2026
…ated_table_existence_request

Fix unauthenticated TablesStatusRequest in interserver mode
pull Bot pushed a commit to admariner/ClickHouse that referenced this pull request Sep 12, 2026
TCPHandler::receivePacketsExpectQuery() handles the packets that arrive
before any Query has been received. Its Data/Scalar case called
processUnexpectedData() and only then threw UNEXPECTED_PACKET_FROM_CLIENT.
That call builds a headerless NativeReader and reads a block, so the
column type name comes off the wire and is handed to DataTypeFactory.

Entering interserver mode requires only the name of a cluster that has a
non-empty <secret>, never the secret itself, and the secret is verified
only inside processQuery(), which this loop never reaches. So an
unauthenticated peer decided which type the server constructed and which
deserializer ran.

The read had no consumer for any peer either: its return value was
discarded, and the exception thrown right after is caught where
"if (!query_state) return;" exits runImpl, so the exception is never
sent, the connection is closed, and no further byte is read from the
socket. runImpl's exception handler already documents that policy ("the
server should not try to skip (parse, decompress) the remaining packets
sent by the client, as it will lead to additional work and unneeded
exposure to unauthenticated connections"); the parse it forbids had
already happened one frame earlier. The adjacent case of the same
switch, processObsoleteIgnoredPartUUIDs(), was given exactly this shape
in ClickHouse#99398.

No setting gates the rejection, deliberately. The TablesStatusRequest
path needed an operator opt-out when it was hardened in ClickHouse#99854 because
it was a served path, and rejecting it broke rolling upgrades (ClickHouse#113602).
Data before Query has always thrown, so there is no working flow to
preserve, and nothing a client can observe changes: the connection
closes with nothing written back, before and after.

The regression test completes an accepted interserver handshake over a
raw socket and then sends a Data packet declaring a column type that
does not exist. Without this change the server logs "Unknown data type
family: NoSuchTypeGroeneAI", which is the proof that a wire-supplied
type name reached DataTypeFactory before authentication. It is extended
into the existing pre-authentication interserver test module rather than
added as a new one, because that module already provides the cluster
with a <secret> and a handshake pinned below the chunked-packets
revision, which is what makes a hand-built packet feasible.

Requested by @ nikitamikhaylov, relayed via OranjeAI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore pr-critical-bugfix pr-must-backport Pull request should be backported intentionally. Use this label with great care! pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants