This is an automated email from the ASF dual-hosted git repository. cgivre pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/drill-mcp.git
commit df7c9d0d9d89546f5aac5e984360bd44d14ed3b8 Author: cgivre <[email protected]> AuthorDate: Wed Aug 12 16:25:59 2026 -0400 fix: strip URL userinfo from REST client error messages config.url is free-form and unvalidated; a password embedded there as userinfo (e.g. http://alice:s3cret@drill:8047) previously reached the model verbatim through DrillError messages raised by RestClient (connection failures, auth failures, JSON-decode errors). Add a _safe_url helper, mirroring the defense client_jdbc.py's _jdbc_url already applies, and use it at every site that echoes config.url. --- drill_mcp/client_rest.py | 39 ++++++++++++++++++++++++++++----------- tests/test_client_rest.py | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/drill_mcp/client_rest.py b/drill_mcp/client_rest.py index 2165ba0..1ca1461 100644 --- a/drill_mcp/client_rest.py +++ b/drill_mcp/client_rest.py @@ -31,6 +31,7 @@ import re from collections.abc import Callable from dataclasses import dataclass, field from typing import Any +from urllib.parse import urlparse, urlunparse import httpx @@ -180,6 +181,22 @@ def _check_query_id(query_id: str) -> str: return query_id +def _safe_url(url: str) -> str: + """Return `url` with any embedded userinfo (e.g. a password) stripped. + + `config.url` is free-form and unvalidated, so nothing stops a value like + `http://alice:s3cret@drill:8047`. Every message that echoes the URL back + to the model must use this instead of the raw config value -- the + `client_jdbc.py` backend already applies this exact defense to its + connection string; the REST backend must not diverge. + """ + parsed = urlparse(url) + if not parsed.hostname: + return url + netloc = parsed.hostname if parsed.port is None else f"{parsed.hostname}:{parsed.port}" + return urlunparse(parsed._replace(netloc=netloc)) + + def _json(response: httpx.Response, url: str) -> Any: """Decode a response body as JSON, converting a decode failure to `DrillError`. @@ -512,23 +529,23 @@ class RestClient: # "/j_security_check", so that check false-positives on success.) if response.status_code >= 400: raise DrillError( - f"authentication endpoint at {self._config.url} returned " + f"authentication endpoint at {_safe_url(self._config.url)} returned " f"HTTP {response.status_code}" ) if _contains_invalid_credentials_marker(response.text): raise DrillError( - f"authentication failed for user {self._config.user!r} at {self._config.url}" + f"authentication failed for user {self._config.user!r} at {_safe_url(self._config.url)}" ) self._authenticated = True def _transport_error(self, exc: httpx.HTTPError) -> DrillError: if isinstance(exc, httpx.TimeoutException): return DrillError( - f"request to {self._config.url} timed out after " + f"request to {_safe_url(self._config.url)} timed out after " f"{self._config.timeout_seconds}s" ) return DrillError( - f"could not reach Drill at {self._config.url} " + f"could not reach Drill at {_safe_url(self._config.url)} " f"(auth mode: {self._config.auth}): {type(exc).__name__}" ) @@ -546,7 +563,7 @@ class RestClient: if response.status_code == 401: raise DrillError( - f"authentication rejected by Drill at {self._config.url} " + f"authentication rejected by Drill at {_safe_url(self._config.url)} " f"for user {self._config.user!r}" ) if response.status_code >= 400: @@ -561,7 +578,7 @@ class RestClient: "/query.json", json={"queryType": "SQL", "query": sql, "autoLimit": max_rows}, ) - payload = _json(response, self._config.url) + payload = _json(response, _safe_url(self._config.url)) rows = payload.get("rows") or [] return QueryResult( columns=payload.get("columns") or [], @@ -594,11 +611,11 @@ class RestClient: def storage_plugins(self) -> list[dict[str, Any]]: response = self._request("GET", "/storage.json") - return redact(_json(response, self._config.url)) + return redact(_json(response, _safe_url(self._config.url))) def cluster_status(self) -> dict[str, Any]: - cluster = _json(self._request("GET", "/cluster.json"), self._config.url) - status = _json(self._request("GET", "/status.json"), self._config.url) + cluster = _json(self._request("GET", "/cluster.json"), _safe_url(self._config.url)) + status = _json(self._request("GET", "/status.json"), _safe_url(self._config.url)) merged = dict(cluster) if isinstance(cluster, dict) else {"cluster": cluster} if isinstance(status, dict): merged.update(status) @@ -609,7 +626,7 @@ class RestClient: def profiles(self, limit: int) -> list[dict[str, Any]]: limit = max(limit, 0) response = self._request("GET", "/profiles.json") - payload = _json(response, self._config.url) + payload = _json(response, _safe_url(self._config.url)) if not isinstance(payload, dict): payload = {} running = payload.get("runningQueries") or [] @@ -619,7 +636,7 @@ class RestClient: def profile(self, query_id: str) -> dict[str, Any]: _check_query_id(query_id) response = self._request("GET", f"/profiles/{query_id}.json") - return _json(response, self._config.url) + return _json(response, _safe_url(self._config.url)) def cancel_query(self, query_id: str) -> str: _check_query_id(query_id) diff --git a/tests/test_client_rest.py b/tests/test_client_rest.py index 983be61..72c4299 100644 --- a/tests/test_client_rest.py +++ b/tests/test_client_rest.py @@ -172,6 +172,27 @@ class TestQuery: assert BASE in str(exc.value) assert "s3cret" not in str(exc.value) + @respx.mock + def test_connection_failure_message_drops_a_password_embedded_in_the_url(self): + # Mirrors client_jdbc.py's + # test_jdbc_url_drops_userinfo_from_a_url_that_embeds_credentials -- + # the REST backend must apply the same defense. config.url is + # free-form and unvalidated, so nothing stops + # DRILL_URL=http://alice:s3cret@drill:8047; every message that + # echoes config.url back to the model must not leak the password + # embedded there. + url = "http://alice:s3cret@drill:8047" + respx.post(f"{url}/j_security_check").mock(side_effect=httpx.ConnectError("refused")) + respx.post(f"{url}/query.json").mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(DrillError) as exc: + make_client(url=url, auth="basic", user="alice", password="s3cret").query( + "SELECT 1", max_rows=1 + ) + message = str(exc.value) + assert "s3cret" not in message + assert "alice" not in message + assert "drill:8047" in message + @respx.mock def test_timeout_is_reported_clearly(self): respx.post(f"{BASE}/query.json").mock(side_effect=httpx.ReadTimeout("slow"))
