Skip to content

Commit 15964a9

Browse files
authored
Don't leak request URL in admin API error messages (#792)
1 parent 735e9f7 commit 15964a9

3 files changed

Lines changed: 61 additions & 3 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Bug Fix
2+
body: Admin API client errors no longer include the full request URL, which could expose internal hostnames
3+
time: 2026-06-03T11:25:56.85535-05:00

src/dbt_mcp/dbt_admin/client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,20 @@ async def _make_request(
5454
response.raise_for_status()
5555
return response.json()
5656
except httpx.HTTPStatusError as e:
57+
# Don't interpolate the httpx error directly: its string contains the
58+
# full request URL, which can leak internal hostnames to the caller.
59+
# The status code plus method/endpoint are enough to act on.
5760
if 400 <= e.response.status_code < 500:
5861
raise InvalidParameterError(
59-
f"API request failed ({e.response.status_code}): {e}"
62+
f"API request failed ({e.response.status_code}) "
63+
f"for {method} {endpoint}"
6064
) from e
61-
raise AdminAPIError(f"API request failed: {e}") from e
65+
raise AdminAPIError(
66+
f"API request failed ({e.response.status_code}) for {method} {endpoint}"
67+
) from e
6268
except httpx.HTTPError as e:
63-
raise AdminAPIError(f"API request failed: {e}") from e
69+
# Same rationale: transport errors also embed the URL in their string.
70+
raise AdminAPIError(f"API request failed for {method} {endpoint}") from e
6471

6572
async def get_account(self, account_id: int) -> dict[str, Any]:
6673
"""Get details for an account."""

tests/unit/dbt_admin/test_client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,54 @@ async def test_make_request_5xx_raises_admin_api_error(client):
135135
await client._make_request("GET", "/test/endpoint")
136136

137137

138+
async def test_make_request_4xx_error_does_not_leak_request_url(client):
139+
internal_url = (
140+
"http://dbt-cloud-app.dbt-cloud.svc.cluster.local:8003"
141+
"/api/v3/accounts/12345/projects/1/environments/?state=1"
142+
)
143+
mock_response = MagicMock()
144+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
145+
f"Client error '404 Not Found' for url '{internal_url}'",
146+
request=MagicMock(),
147+
response=MagicMock(status_code=404),
148+
)
149+
150+
mock_client = create_mock_httpx_client(mock_response)
151+
152+
with patch("httpx.AsyncClient", return_value=mock_client):
153+
with pytest.raises(InvalidParameterError) as exc_info:
154+
await client._make_request(
155+
"GET", "/api/v3/accounts/12345/projects/1/environments/?state=1"
156+
)
157+
158+
message = str(exc_info.value)
159+
assert "dbt-cloud-app.dbt-cloud.svc.cluster.local" not in message
160+
assert internal_url not in message
161+
assert "404" in message
162+
163+
164+
async def test_make_request_5xx_error_does_not_leak_request_url(client):
165+
internal_url = (
166+
"http://dbt-cloud-app.dbt-cloud.svc.cluster.local:8003/api/v2/accounts/12345/"
167+
)
168+
mock_response = MagicMock()
169+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
170+
f"Server error '500 Internal Server Error' for url '{internal_url}'",
171+
request=MagicMock(),
172+
response=MagicMock(status_code=500),
173+
)
174+
175+
mock_client = create_mock_httpx_client(mock_response)
176+
177+
with patch("httpx.AsyncClient", return_value=mock_client):
178+
with pytest.raises(AdminAPIError) as exc_info:
179+
await client._make_request("GET", "/api/v2/accounts/12345/")
180+
181+
message = str(exc_info.value)
182+
assert "dbt-cloud-app.dbt-cloud.svc.cluster.local" not in message
183+
assert internal_url not in message
184+
185+
138186
async def test_list_jobs(client):
139187
mock_response = MagicMock()
140188
mock_response.json.return_value = {

0 commit comments

Comments
 (0)