This is an automated email from the ASF dual-hosted git repository.

cgivre pushed a commit to branch feat/drill-mcp-server
in repository https://gitbox.apache.org/repos/asf/drill-mcp.git

commit 3249bfce57dd32b4290edda62896b584a7b22fad
Author: cgivre <[email protected]>
AuthorDate: Wed Aug 12 01:09:58 2026 -0400

    fix: scrub credentials from JDBC error paths, close cursors, drop dead code
    
    Two criticals from review:
    - _jdbc_url() built the connection string from urlparse().netloc, which
      includes userinfo (user:pass@host); a URL with embedded credentials put
      the password directly into the JDBC connection string and from there
      into any driver error that echoed it. Build from hostname/port only.
    - Driver exceptions can also echo the password we hand to
      jaydebeapi.connect() directly (e.g. an auth-failure message). Add
      _scrub() and apply it to every DrillError built from driver-supplied
      text: connect, query, and close. The prior test asserted the
      unscrubbed message verbatim, so it passed only because the leak was
      present; inverted it to assert the password is absent, and added
      matching cases for the query and close paths.
    
    Also, from the same review:
    - query() now closes its cursor via contextlib.closing instead of
      leaking a statement handle per call
    - close() clears the stale connection handle in a finally and wraps a
      failing connection.close() in DrillError instead of letting it escape
      raw
    - removed the unreachable `if jaydebeapi is None` branch and the dead
      `except DrillError: raise` in query()
    - JDBC's truncated flag now matches REST's max_rows>0 guard
    - test_schemas_uses_information_schema now asserts the emitted SQL,
      not just the mapped result
    - noted that Query is invoked positionally
---
 drill_mcp/client_jdbc.py  | 68 +++++++++++++++++++++++++++++++----------------
 drill_mcp/client_rest.py  |  3 +++
 tests/test_client_jdbc.py | 50 +++++++++++++++++++++++++++++-----
 3 files changed, 92 insertions(+), 29 deletions(-)

diff --git a/drill_mcp/client_jdbc.py b/drill_mcp/client_jdbc.py
index eef2d15..2b103c6 100644
--- a/drill_mcp/client_jdbc.py
+++ b/drill_mcp/client_jdbc.py
@@ -28,6 +28,7 @@ so `JdbcClient` deliberately does not implement 
`storage_plugins`,
 
 from __future__ import annotations
 
+from contextlib import closing
 from typing import Any
 from urllib.parse import urlparse
 
@@ -52,17 +53,38 @@ class JdbcClient:
     # -- connection --------------------------------------------------------
 
     def _jdbc_url(self) -> str:
-        # Credentials are never embedded in the URL -- they are passed to
-        # jaydebeapi.connect() as a separate argument (see _connect below) --
-        # so this string, and anything derived from it in an error message,
-        # cannot leak the password.
-        host = urlparse(self._config.url)
-        netloc = host.netloc or host.path
+        # Built from hostname/port ONLY -- never `netloc`, which for a URL
+        # like "http://alice:s3cret@drill:8047"; includes the userinfo
+        # ("alice:s3cret@drill:8047"). config.url is free-form and
+        # unvalidated, so a password embedded there must never reach the
+        # connection string, or it becomes reachable through a driver
+        # exception that echoes its arguments back (see _scrub, which
+        # handles the remaining case: the driver echoing the password we
+        # pass to jaydebeapi.connect() separately, below).
+        parsed = urlparse(self._config.url)
+        host = parsed.hostname or parsed.path
+        netloc = f"{host}:{parsed.port}" if parsed.port else host
         url = f"jdbc:drill:drillbit={netloc}"
         if self._config.auth == "kerberos":
             url += ";auth=kerberos"
         return url
 
+    def _scrub(self, text: str) -> str:
+        """Remove the configured password from driver-supplied error text.
+
+        `redact()` in `client_rest.py` doesn't apply here -- it's key-based
+        over dicts/lists/tuples and passes a bare `str` through unchanged.
+        The driver's exception text is exactly that: a bare string we do not
+        control, and it can contain the password we handed to
+        `jaydebeapi.connect()` (e.g. an auth-failure message that echoes its
+        arguments) or, via the JDBC URL, one a caller embedded in `config.url`
+        as userinfo despite `_jdbc_url` no longer forwarding it verbatim.
+        """
+        password = self._config.password
+        if password:
+            text = text.replace(password, "***REDACTED***")
+        return text
+
     def _connect(self) -> Any:
         if self._connection is not None:
             return self._connection
@@ -72,10 +94,6 @@ class JdbcClient:
             raise DrillError(
                 "the JDBC backend requires the jdbc extra: pip install 
drill-mcp[jdbc]"
             ) from exc
-        if jaydebeapi is None:
-            raise DrillError(
-                "the JDBC backend requires the jdbc extra: pip install 
drill-mcp[jdbc]"
-            )
         credentials = (
             [self._config.user, self._config.password]
             if self._config.auth == "basic"
@@ -89,15 +107,21 @@ class JdbcClient:
                 jars=[self._config.jdbc_driver_path],
             )
         except Exception as exc:
-            # `exc` is whatever the driver reports; it is never supplemented
-            # here with the URL or credentials, so this message can only leak
-            # a credential if the driver itself already put one in `exc`.
-            raise DrillError(f"could not connect to Drill over JDBC: {exc}") 
from exc
+            raise DrillError(
+                f"could not connect to Drill over JDBC: 
{self._scrub(str(exc))}"
+            ) from exc
         return self._connection
 
     def close(self) -> None:
-        if self._connection is not None:
+        if self._connection is None:
+            return
+        try:
             self._connection.close()
+        except Exception as exc:
+            raise DrillError(
+                f"could not close the JDBC connection: {self._scrub(str(exc))}"
+            ) from exc
+        finally:
             self._connection = None
 
     # -- queries -------------------------------------------------------------
@@ -105,18 +129,16 @@ class JdbcClient:
     def query(self, sql: str, max_rows: int) -> QueryResult:
         connection = self._connect()
         try:
-            cursor = connection.cursor()
-            cursor.execute(sql)
-            rows = cursor.fetchmany(max_rows)
-            columns = [description[0] for description in cursor.description or 
[]]
-        except DrillError:
-            raise
+            with closing(connection.cursor()) as cursor:
+                cursor.execute(sql)
+                rows = cursor.fetchmany(max_rows)
+                columns = [description[0] for description in 
cursor.description or []]
         except Exception as exc:
-            raise DrillError(str(exc)) from exc
+            raise DrillError(self._scrub(str(exc))) from exc
         return QueryResult(
             columns=columns,
             rows=[dict(zip(columns, row)) for row in rows],
-            truncated=len(rows) >= max_rows,
+            truncated=max_rows > 0 and len(rows) >= max_rows,
         )
 
     # -- metadata 
--------------------------------------------------------------
diff --git a/drill_mcp/client_rest.py b/drill_mcp/client_rest.py
index bed9076..b890f13 100644
--- a/drill_mcp/client_rest.py
+++ b/drill_mcp/client_rest.py
@@ -210,6 +210,9 @@ def _error_text(response: httpx.Response) -> str:
 # so `JdbcClient` can share the exact same identifier-quoting and file-plugin
 # branching instead of duplicating ~80 lines of security-relevant logic.
 
+# Called positionally as `query(sql, max_rows)`, not with keyword arguments --
+# RestClient.query and JdbcClient.query both accept `max_rows` positionally,
+# but a bound method's parameter name is not part of `Callable`'s contract.
 Query = Callable[[str, int], QueryResult]
 
 
diff --git a/tests/test_client_jdbc.py b/tests/test_client_jdbc.py
index 3fd8011..8e64500 100644
--- a/tests/test_client_jdbc.py
+++ b/tests/test_client_jdbc.py
@@ -60,6 +60,19 @@ def 
test_connect_uses_the_configured_driver_and_url(fake_jaydebeapi):
     assert args.kwargs["jars"] == ["/opt/drill-jdbc-all.jar"]
 
 
+def 
test_jdbc_url_drops_userinfo_from_a_url_that_embeds_credentials(fake_jaydebeapi):
+    # config.url is free-form and unvalidated; nothing stops
+    # DRILL_URL=http://alice:s3cret@drill:8047. The JDBC connection string
+    # must be built from hostname/port only, never from `netloc` (which
+    # includes "alice:s3cret@"), or the password ends up in the connection
+    # string and from there in any driver error that echoes it.
+    make_client(url="http://alice:s3cret@drill:8047";).query("SELECT 1", 
max_rows=1)
+    url = fake_jaydebeapi.connect.call_args.args[1]
+    assert url == "jdbc:drill:drillbit=drill:8047"
+    assert "s3cret" not in url
+    assert "alice" not in url
+
+
 def test_query_returns_columns_and_rows(fake_jaydebeapi):
     result = make_client().query("SELECT 1", max_rows=10)
     assert result.columns == ["a", "b"]
@@ -97,6 +110,8 @@ def test_schemas_uses_information_schema(fake_jaydebeapi):
     cursor.description = [("SCHEMA_NAME", None), ("TYPE", None)]
     cursor.fetchmany.return_value = [("dfs.tmp", "file")]
     assert make_client().schemas() == [{"name": "dfs.tmp", "type": "file"}]
+    sql = cursor.execute.call_args.args[0]
+    assert "INFORMATION_SCHEMA" in sql
 
 
 def test_tables_rejects_injection(fake_jaydebeapi):
@@ -125,13 +140,36 @@ def 
test_driver_error_does_not_leak_the_password(fake_jaydebeapi):
     fake_jaydebeapi.connect.side_effect = RuntimeError("auth failed for user 
alice/s3cret")
     with pytest.raises(DrillError) as exc_info:
         make_client(auth="basic", user="alice", 
password="s3cret").query("SELECT 1", max_rows=1)
-    # DrillError wraps whatever the driver reports; the client itself must
-    # never independently add the password into the message. This asserts
-    # the message is exactly the driver's own text, not a client-composed
-    # string that embeds config.password.
-    assert str(exc_info.value) == (
-        "could not connect to Drill over JDBC: auth failed for user 
alice/s3cret"
+    message = str(exc_info.value)
+    # The driver's exception text can itself contain the password we passed
+    # to jaydebeapi.connect() (e.g. an auth-failure message that echoes its
+    # arguments). The wrapper must scrub it, not merely avoid adding it a
+    # second time.
+    assert "s3cret" not in message
+    # The wrapper must still be informative: the non-secret part of the
+    # driver's text survives, just with the password redacted.
+    assert "auth failed for user alice/***REDACTED***" in message
+
+
+def test_query_error_does_not_leak_the_password(fake_jaydebeapi):
+    cursor = fake_jaydebeapi.connect.return_value.cursor.return_value
+    cursor.execute.side_effect = RuntimeError("query failed: password was 
s3cret")
+    with pytest.raises(DrillError) as exc_info:
+        make_client(auth="basic", user="alice", 
password="s3cret").query("SELECT 1", max_rows=1)
+    assert "s3cret" not in str(exc_info.value)
+
+
+def test_close_error_does_not_leak_the_password(fake_jaydebeapi):
+    client = make_client(auth="basic", user="alice", password="s3cret")
+    client.query("SELECT 1", max_rows=1)
+    fake_jaydebeapi.connect.return_value.close.side_effect = RuntimeError(
+        "close failed: password was s3cret"
     )
+    with pytest.raises(DrillError) as exc_info:
+        client.close()
+    assert "s3cret" not in str(exc_info.value)
+    # The stale handle must not linger after a failed close.
+    assert client._connection is None
 
 
 def test_management_methods_are_not_implemented(fake_jaydebeapi):

Reply via email to