URL userinfo password echoed verbatim in HTTP error messages and debug logs
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
ReadWriteBufferFromHTTPpasses the rawPoco::URIthroughtoString(), whose authority includes the userinfo. The masking the PR extends works on the create-query AST (maskURIPasswordon the argument) and cannot cover a string built at request time, so the secret reaches the user through a sink thecanDisplaySecretsgate (src/Interpreters/formatWithPossiblyHidingSecrets.cpp:17-22) does not guard.
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:
src/IO/ReadWriteBufferFromHTTP.cpp:279— assertResponseIsOk(current_uri.toString(), ...) - any non-2xx response, user-facing exception (proven)src/IO/ReadWriteBufferFromHTTP.cpp:166— getFileName() returns initial_uri.toString(); IInputFormat appends it as(in file/uri ...)(proven)src/Processors/Formats/IInputFormat.cpp:40— e.addMessage(fmt::format("(in file/uri {})", file_name)) - second copy of the URL in the same messagesrc/IO/ReadWriteBufferFromHTTP.cpp:300— TOO_MANY_REDIRECTS exception with initial_uri.toString()src/IO/ReadWriteBufferFromHTTP.cpp:376— LOG_DEBUG "Failed to make request to '{}'" - streamed to any client with send_logs_level='debug' (proven), and to the server log at the shipped default leveltracesrc/IO/ReadWriteBufferFromHTTP.cpp:393— LOG_TRACE, same message on the retry pathsrc/IO/ReadWriteBufferFromHTTP.cpp:873— setCredentialsFromURL copies the userinfo into HTTPBasicCredentials but leaves it in the URI
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 fixRender 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.
Open risks:
- Other object storages that accept userinfo in their URL (the
S3database 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 insidegetFileNameitself.
Found during automated review of PR #120239. Severity P1 · Finding h_pr120239_001
cc @groeneai @evillique (from #120239)
Source: ClickHouse/ClickHouse