#120425·ClickHouse

URL userinfo password echoed verbatim in HTTP error messages and debug logs

Author: clickgapaiCreated Sep 16, 2026Updated Sep 17, 2026
Labelscomp-table-functions

Describe what's wrong

A password stored in a URL database engine's base URL (or in a URL table engine's URL, or a named collection's url) is returned in cleartext to any user whose read fails: Received error from remote server http://leak_user:SEKRIT_PW@localhost:19130/no_such_endpoint.csv. HTTP status code: 404 .... The same query's SHOW CREATE DATABASE correctly prints [HIDDEN]. The reader needs no displaySecretsInShowAndSelect grant, and on a default install the password is unreachable through every display surface because the server setting display_secrets_in_show_and_select defaults to false.

  • Root cause: Every message-rendering site in ReadWriteBufferFromHTTP passes the raw Poco::URI through toString(), whose authority includes the userinfo. The masking the PR extends works on the create-query AST (maskURIPassword on the argument) and cannot cover a string built at request time, so the secret reaches the user through a sink the canDisplaySecrets gate (src/Interpreters/formatWithPossiblyHidingSecrets.cpp:17-22) does not guard.
Analysis details (evidence, affected locations, impact)

Why we believe this is a bug: Reader issues SELECT * FROM <url_db>.'<relative>' -> DatabaseURL::getTableImpl (src/Databases/DatabaseURL.cpp:351) resolves it to url('<base_url><relative>') with the userinfo intact -> ReadWriteBufferFromHTTP::callImpl -> assertResponseIsOk(current_uri.toString(), ...) (src/IO/ReadWriteBufferFromHTTP.cpp:279) renders Poco::URI::toString(), which includes user:password@, into the exception message. The credentials were already copied out into Poco::Net::HTTPBasicCredentials by setCredentialsFromURL (src/IO/ReadWriteBufferFromHTTP.cpp:873-891), which never strips the userinfo from the URI it parsed, so every later render of that URI carries the password.

Affected locations:

Impact: Any reader of a credential-bearing URL object recovers the password by making the request fail - for a URL database the reader picks the relative table name, so a nonexistent path is enough (404). This defeats the guarantee this PR establishes for SHOW CREATE DATABASE / system.databases.engine_full and the three-way canDisplaySecrets gate (server setting + format setting + displaySecretsInShowAndSelect grant), all of which deny the same secret on a default install. Second sink: send_logs_level = 'debug' streams the same URL to the client without any error at all, and the shipped config.xml logs at trace, so the password also lands in the server log file (and system.text_log where enabled).

Does it reproduce on most recent release?

Yes — confirmed on current master (commit 44a2a51a4ce0).

How to reproduce

# A URL password that SHOW CREATE hides must not come back in the error message of a failed read.

BASE_URL="http://leak_user:SEKRIT_PW@localhost:${CLICKHOUSE_PORT_HTTP}"
DB="default_05218"

leaked() { grep -qF SEKRIT_PW && echo 'LEAKED' || echo 'ok'; }

echo '--- URL database engine'
clickhouse-client -q "DROP DATABASE IF EXISTS ${DB}"
clickhouse-client -q "CREATE DATABASE ${DB} ENGINE = URL('${BASE_URL}/')"
clickhouse-client -q "SHOW CREATE DATABASE ${DB} SETTINGS format_display_secrets_in_show_and_select = 0" 2>&1 | leaked
clickhouse-client -q "SELECT * FROM ${DB}.\`no_such_endpoint.csv\` SETTINGS http_max_tries = 1, format_display_secrets_in_show_and_select = 0" 2>&1 | leaked
clickhouse-client -q "DROP DATABASE ${DB}"

echo '--- URL table engine'
clickhouse-client -q "DROP TABLE IF EXISTS t_05218"
clickhouse-client -q "CREATE TABLE t_05218 (x UInt8) ENGINE = URL('${BASE_URL}/no_such_endpoint.csv', 'CSV')"
clickhouse-client -q "SHOW CREATE TABLE t_05218 SETTINGS format_display_secrets_in_show_and_select = 0" 2>&1 | leaked
clickhouse-client -q "SELECT * FROM t_05218 SETTINGS http_max_tries = 1, format_display_secrets_in_show_and_select = 0" 2>&1 | leaked
clickhouse-client -q "DROP TABLE t_05218"

Expected behavior

Expected output of the reproducer above:

--- URL database engine
ok
ok
--- URL table engine
ok
ok

Error message and/or stacktrace

Actual output of the reproducer above on master (44a2a51a4ce0):

--- URL database engine
ok
LEAKED
--- URL table engine
ok
LEAKED

The message behind the second line, verbatim:
Code: 86. DB::Exception: Received from 127.0.0.1:19030. DB::HTTPException. DB::HTTPException: Received error from remote server http://leak_user:SEKRIT_PW@localhost:19130/no_such_endpoint.csv. HTTP status code: 404 'Not Found', body length: 398 bytes, body: 'There is no handle /no_such_e
Suggested fix

Render the URI for humans with the userinfo removed. The credentials are already in HTTPBasicCredentials, so nothing functional depends on keeping it: add a helper (e.g. initial_uri / current_uri copies with setUserInfo(""), or run the existing maskURIUserinfo from src/Common/maskURIPassword.h over the rendered string) and use it at the assertResponseIsOk argument, getFileName, the TOO_MANY_REDIRECTS message and the two LOG_DEBUG/LOG_TRACE sites. Trade-off: mask only the userinfo, not the whole URI - tests/queries/0_stateless/04070_url_base_setting.sh asserts on the resolved path in that debug line and would break if the host/path were hidden. Related prior art: issue #106441 / PR #106916 made Exception::addMessage maskable by query_masking_rules, which is an admin-configured mitigation (the shipped config.xml has the whole query_masking_rules block commented out) and does not cover the default install.

Additional context

Open risks:

  • Other object storages that accept userinfo in their URL (the S3 database engine's presigned/userinfo URLs, HDFS) reach their own error paths; I checked only the HTTP buffer.
  • getFileName() also feeds non-message consumers of the read buffer, so the masking belongs at the message sites rather than inside getFileName itself.

Found during automated review of PR #120239. Severity P1 · Finding h_pr120239_001

cc @groeneai @evillique (from #120239)