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 6e63ceaa218672f6d34af978cb5a9506026f497b Author: cgivre <[email protected]> AuthorDate: Wed Aug 12 00:53:07 2026 -0400 fix: address six minor findings from Task 6 review - reject a bare ".." (or empty) dot-segment in quote_identifier, closing a directory-traversal gap in fetch_columns's file-plugin path - add direct unit tests for quote_identifier_path/quote_identifier rejecting backticks, and rename two tests that claimed coverage they did not provide - factor response.json() decoding into a _json() helper and guard all six sites (query, storage_plugins, cluster_status x2, profiles, profile) against a non-JSON body - profiles() tolerates a non-dict payload and clamps a negative limit to 0 - add a direct test for plugin_type - drop leftover ^...$ anchors from _IDENTIFIER for consistency with _FILE_IDENTIFIER --- drill_mcp/client_rest.py | 57 +++++++++++++++++++++-------- tests/test_client_rest.py | 93 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/drill_mcp/client_rest.py b/drill_mcp/client_rest.py index d02fdf9..bed9076 100644 --- a/drill_mcp/client_rest.py +++ b/drill_mcp/client_rest.py @@ -47,7 +47,7 @@ from .redact import redact # empty string never matches, and `quote_literal_path` splits on "." and # validates every segment, so a lone "." (which splits into two empty # segments) is rejected too. -_IDENTIFIER = re.compile(r"^[A-Za-z0-9_$-]+$") +_IDENTIFIER = re.compile(r"[A-Za-z0-9_$-]+") # Drill's j_security_check returns HTTP 200 even on a wrong password; the only # signal is this marker string inside the HTML error page body. Matched @@ -135,6 +135,20 @@ def quote_identifier_path(value: str) -> str: _FILE_IDENTIFIER = re.compile(r"[A-Za-z0-9_$.-]+") +def _is_valid_file_identifier(value: str) -> bool: + """`_FILE_IDENTIFIER` plus a check that no "." segment is empty or "..". + + `_FILE_IDENTIFIER` alone permits a bare ".." (or a leading/trailing/ + doubled dot), which Drill treats as a directory reference -- e.g. + `columns("dfs.tmp", "..")` would otherwise reach the workspace's parent. + "/" is excluded from the character class, so this can never traverse more + than one level or reach a specific file, but it should still be rejected. + """ + if not _FILE_IDENTIFIER.fullmatch(value): + return False + return all(part not in ("", "..") for part in value.split(".")) + + def quote_identifier(value: str) -> str: """Quote a single identifier that may itself contain dots, e.g. a filename. @@ -145,7 +159,7 @@ def quote_identifier(value: str) -> str: quotes plugin.`workspace`.`file.ext` the same way). Reject rather than escape -- same trust boundary as `quote_identifier_path`. """ - if not _FILE_IDENTIFIER.fullmatch(value): + if not _is_valid_file_identifier(value): raise DrillError(f"invalid identifier: {value!r}") return f"`{value}`" @@ -159,6 +173,19 @@ def _check_query_id(query_id: str) -> str: return query_id +def _json(response: httpx.Response, url: str) -> Any: + """Decode a response body as JSON, converting a decode failure to `DrillError`. + + A 200 response carrying HTML -- exactly the auth-proxy scenario `_login` + exists to handle -- must never raise a raw `json.JSONDecodeError` out of + the client boundary. + """ + try: + return response.json() + except ValueError as exc: + raise DrillError(f"Drill at {url} returned a non-JSON response") from exc + + def _error_text(response: httpx.Response) -> str: """Drill's own error text is what a model needs to fix its SQL. Truncate it.""" try: @@ -250,7 +277,7 @@ def fetch_columns(query: Query, schema: str, table: str) -> list[dict[str, Any]] # `_FILE_IDENTIFIER` (not `_IDENTIFIER`) because file-plugin table # names are filenames and may contain a literal "." (e.g. "sales.csv") # as ONE identifier -- see `quote_identifier`. - if not _FILE_IDENTIFIER.fullmatch(table): + if not _is_valid_file_identifier(table): raise DrillError(f"invalid identifier: {table!r}") # Same split: file plugins have dynamic schemas and no @@ -391,12 +418,7 @@ class RestClient: "/query.json", json={"queryType": "SQL", "query": sql, "autoLimit": max_rows}, ) - try: - payload = response.json() - except ValueError as exc: - raise DrillError( - f"Drill at {self._config.url} returned a non-JSON response" - ) from exc + payload = _json(response, self._config.url) rows = payload.get("rows") or [] return QueryResult( columns=payload.get("columns") or [], @@ -427,12 +449,12 @@ class RestClient: # -- management -------------------------------------------------------- def storage_plugins(self) -> list[dict[str, Any]]: - payload = self._request("GET", "/storage.json").json() - return redact(payload) + response = self._request("GET", "/storage.json") + return redact(_json(response, self._config.url)) def cluster_status(self) -> dict[str, Any]: - cluster = self._request("GET", "/cluster.json").json() - status = self._request("GET", "/status.json").json() + cluster = _json(self._request("GET", "/cluster.json"), self._config.url) + status = _json(self._request("GET", "/status.json"), self._config.url) merged = dict(cluster) if isinstance(cluster, dict) else {"cluster": cluster} if isinstance(status, dict): merged.update(status) @@ -441,14 +463,19 @@ class RestClient: return merged def profiles(self, limit: int) -> list[dict[str, Any]]: - payload = self._request("GET", "/profiles.json").json() + limit = max(limit, 0) + response = self._request("GET", "/profiles.json") + payload = _json(response, self._config.url) + if not isinstance(payload, dict): + payload = {} running = payload.get("runningQueries") or [] finished = payload.get("finishedQueries") or [] return (list(running) + list(finished))[:limit] def profile(self, query_id: str) -> dict[str, Any]: _check_query_id(query_id) - return self._request("GET", f"/profiles/{query_id}.json").json() + response = self._request("GET", f"/profiles/{query_id}.json") + return _json(response, 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 3d32e92..fcf2b4f 100644 --- a/tests/test_client_rest.py +++ b/tests/test_client_rest.py @@ -24,7 +24,14 @@ import httpx import pytest import respx -from drill_mcp.client_rest import DrillError, RestClient, quote_literal, quote_literal_path +from drill_mcp.client_rest import ( + DrillError, + RestClient, + quote_identifier, + quote_identifier_path, + quote_literal, + quote_literal_path, +) from drill_mcp.config import load_config BASE = "http://drill:8047" @@ -83,6 +90,22 @@ class TestQuoting: with pytest.raises(DrillError, match="invalid identifier"): quote_literal_path(bad) + def test_identifier_path_rejects_a_backtick(self): + with pytest.raises(DrillError, match="invalid identifier"): + quote_identifier_path("dfs`x") + + def test_identifier_rejects_a_backtick(self): + with pytest.raises(DrillError, match="invalid identifier"): + quote_identifier("a`b") + + def test_identifier_rejects_a_bare_dot_dot_segment(self): + with pytest.raises(DrillError, match="invalid identifier"): + quote_identifier("..") + + def test_identifier_rejects_a_dot_dot_segment_within_a_longer_name(self): + with pytest.raises(DrillError, match="invalid identifier"): + quote_identifier("foo...bar") + class TestQuery: @respx.mock @@ -420,6 +443,16 @@ class TestMetadata: with pytest.raises(DrillError, match="invalid identifier"): make_client().tables("dfs'; DROP TABLE x --") + @respx.mock + def test_plugin_type_returns_the_schema_type(self): + route = respx.post(f"{BASE}/query.json").mock( + return_value=query_response( + ["SCHEMA_NAME", "TYPE"], [{"SCHEMA_NAME": "dfs.tmp", "TYPE": "file"}] + ) + ) + assert make_client().plugin_type("dfs.tmp") == "file" + assert b"'dfs.tmp'" in route.calls.last.request.read() + class TestFilePluginMetadata: """File plugins are absent from INFORMATION_SCHEMA; they need SHOW FILES.""" @@ -527,12 +560,21 @@ class TestFilePluginMetadata: assert b"`README`" in route.calls[1].request.read() @respx.mock - def test_columns_rejects_a_backtick_in_the_table_name(self): + def test_columns_table_name_guard_rejects_a_backtick_before_any_query_fires(self): + # The `_FILE_IDENTIFIER` guard in `fetch_columns` catches this before + # `plugin_type` (and thus `quote_identifier_path`) is ever reached. route = respx.post(f"{BASE}/query.json").mock(return_value=self._schemata("file")) with pytest.raises(DrillError, match="invalid identifier"): make_client().columns("dfs.tmp", "sales`; DROP TABLE x --.csv") assert not route.called + @respx.mock + def test_columns_rejects_a_bare_dot_dot_table_name(self): + route = respx.post(f"{BASE}/query.json").mock(return_value=self._schemata("file")) + with pytest.raises(DrillError, match="invalid identifier"): + make_client().columns("dfs.tmp", "..") + assert not route.called + @respx.mock def test_columns_uses_information_schema_for_a_non_file_plugin(self): route = respx.post(f"{BASE}/query.json").mock( @@ -559,7 +601,9 @@ class TestFilePluginMetadata: assert make_client().tables("nope") == [] @respx.mock - def test_show_files_path_still_rejects_injection(self): + def test_tables_schema_name_is_rejected_before_the_plugin_type_lookup(self): + # `plugin_type`'s own `quote_literal_path` call rejects this before + # `quote_identifier_path` (the SHOW FILES path) is ever reached. respx.post(f"{BASE}/query.json").mock(return_value=self._schemata("file")) with pytest.raises(DrillError, match="invalid identifier"): make_client().tables("dfs`; DROP TABLE x --") @@ -588,6 +632,16 @@ class TestManagement: assert plugins[0]["config"]["fs.s3a.secret.key"] == "***REDACTED***" assert plugins[0]["name"] == "s3" + @respx.mock + def test_storage_plugins_non_json_response_is_a_drill_error(self): + # A 200 response carrying HTML -- the auth-proxy scenario `_login` + # exists to handle -- must not raise a raw JSONDecodeError. + respx.get(f"{BASE}/storage.json").mock( + return_value=httpx.Response(200, text="<html>not json</html>") + ) + with pytest.raises(DrillError, match="non-JSON response"): + make_client().storage_plugins() + @respx.mock def test_cluster_status_merges_cluster_and_status(self): respx.get(f"{BASE}/cluster.json").mock( @@ -626,6 +680,31 @@ class TestManagement: ) assert make_client().profiles(limit=5)[0]["queryId"] == "live" + @respx.mock + def test_profiles_tolerates_a_non_dict_payload(self): + respx.get(f"{BASE}/profiles.json").mock( + return_value=httpx.Response(200, json=["unexpected", "list", "payload"]) + ) + assert make_client().profiles(limit=5) == [] + + @respx.mock + def test_profiles_clamps_a_negative_limit_to_zero(self): + respx.get(f"{BASE}/profiles.json").mock( + return_value=httpx.Response( + 200, + json={"runningQueries": [{"queryId": "live"}], "finishedQueries": []}, + ) + ) + assert make_client().profiles(limit=-5) == [] + + @respx.mock + def test_profiles_non_json_response_is_a_drill_error(self): + respx.get(f"{BASE}/profiles.json").mock( + return_value=httpx.Response(200, text="<html>not json</html>") + ) + with pytest.raises(DrillError, match="non-JSON response"): + make_client().profiles(limit=5) + @respx.mock def test_profile_fetches_one_query(self): respx.get(f"{BASE}/profiles/abc.json").mock( @@ -638,6 +717,14 @@ class TestManagement: with pytest.raises(DrillError, match="invalid"): make_client().profile("../../etc/passwd") + @respx.mock + def test_profile_non_json_response_is_a_drill_error(self): + respx.get(f"{BASE}/profiles/abc.json").mock( + return_value=httpx.Response(200, text="<html>not json</html>") + ) + with pytest.raises(DrillError, match="non-JSON response"): + make_client().profile("abc") + @respx.mock def test_cancel_query(self): route = respx.get(f"{BASE}/profiles/cancel/abc").mock(
