Skip to content

Commit 07b2917

Browse files
olaservoclaude
andcommitted
fix(fetch): keep scheme lock under --allow-internal-ips; INVALID_PARAMS
Addresses Copilot review feedback on #4497: - The scheme lock (http/https only) is now always enforced. Previously --allow-internal-ips skipped _validate_url_is_safe entirely, which also disabled the scheme check; the flag now only relaxes the private-IP check (check_private_ips=False) and never unlocks file:// or other non-http(s) schemes. Added a regression test asserting file:// is refused (and the HTTP client is never called) under the bypass. - _resolve_host_ips now raises INVALID_PARAMS (not INTERNAL_ERROR) on DNS failure / no parseable IPs, since these are caller-supplied input errors, matching the other URL-validation errors. - Redirect loop enforces the cap before following the next hop, so it no longer processes MAX_REDIRECTS+1 redirect responses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent afb9720 commit 07b2917

2 files changed

Lines changed: 48 additions & 20 deletions

File tree

src/fetch/src/mcp_server_fetch/server.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def _resolve_host_ips(
133133
)
134134
except socket.gaierror as e:
135135
raise McpError(ErrorData(
136-
code=INTERNAL_ERROR,
136+
code=INVALID_PARAMS,
137137
message=f"Failed to resolve host {host}: {e}",
138138
))
139139

@@ -151,18 +151,19 @@ async def _resolve_host_ips(
151151
# classify, refuse rather than fall through to an empty (allow-all) check.
152152
if not ips:
153153
raise McpError(ErrorData(
154-
code=INTERNAL_ERROR,
154+
code=INVALID_PARAMS,
155155
message=f"Failed to resolve host {host} to a usable IP address.",
156156
))
157157
return ips
158158

159159

160-
async def _validate_url_is_safe(url: str) -> None:
160+
async def _validate_url_is_safe(url: str, *, check_private_ips: bool = True) -> None:
161161
"""Guard a URL against SSRF before it is fetched.
162162
163163
Rejects non-http(s) schemes and any URL whose host resolves to a
164-
non-public IP address. Servers started with --allow-internal-ips skip
165-
this check entirely.
164+
non-public IP address. The scheme (and host-presence) check is always
165+
enforced; --allow-internal-ips only relaxes the private-IP check
166+
(check_private_ips=False), so it never unlocks non-http(s) schemes.
166167
"""
167168
parsed = urlparse(url)
168169
if parsed.scheme not in ("http", "https"):
@@ -178,6 +179,9 @@ async def _validate_url_is_safe(url: str) -> None:
178179
message=f"Cannot fetch {url}: URL has no host.",
179180
))
180181

182+
if not check_private_ips:
183+
return
184+
181185
for ip in await _resolve_host_ips(host, parsed.port, parsed.scheme):
182186
if _is_blocked_ip(ip):
183187
raise McpError(ErrorData(
@@ -208,27 +212,32 @@ async def _get_following_redirects(
208212
from httpx import URL
209213

210214
current_url = url
211-
for _ in range(MAX_REDIRECTS + 1):
212-
if not allow_internal_ips:
213-
await _validate_url_is_safe(current_url)
215+
redirects = 0
216+
while True:
217+
# The scheme lock is always enforced; --allow-internal-ips only relaxes
218+
# the private-IP check, so it can never unlock file:// et al.
219+
await _validate_url_is_safe(
220+
current_url, check_private_ips=not allow_internal_ips
221+
)
214222
response = await client.get(
215223
current_url,
216224
follow_redirects=False,
217225
headers=headers,
218226
timeout=timeout,
219227
)
220-
if response.status_code in REDIRECT_STATUS_CODES:
221-
location = response.headers.get("location")
222-
if not location:
223-
return response
224-
current_url = str(URL(current_url).join(location))
225-
continue
226-
return response
227-
228-
raise McpError(ErrorData(
229-
code=INTERNAL_ERROR,
230-
message=f"Cannot fetch {url}: exceeded the maximum of {MAX_REDIRECTS} redirects.",
231-
))
228+
if response.status_code not in REDIRECT_STATUS_CODES:
229+
return response
230+
location = response.headers.get("location")
231+
if not location:
232+
return response
233+
# Enforce the cap before following (and re-validating) the next hop.
234+
if redirects >= MAX_REDIRECTS:
235+
raise McpError(ErrorData(
236+
code=INTERNAL_ERROR,
237+
message=f"Cannot fetch {url}: exceeded the maximum of {MAX_REDIRECTS} redirects.",
238+
))
239+
redirects += 1
240+
current_url = str(URL(current_url).join(location))
232241

233242

234243
async def check_may_autonomously_fetch_url(url: str, user_agent: str, proxy_url: str | None = None, allow_internal_ips: bool = False) -> None:

src/fetch/tests/test_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,22 @@ async def test_fails_closed_when_no_resolved_ip_parses(self):
478478
with patch.object(loop, "getaddrinfo", new=AsyncMock(return_value=fake)):
479479
with pytest.raises(McpError):
480480
await _validate_url_is_safe("http://weird.example/")
481+
482+
@pytest.mark.asyncio
483+
async def test_allow_internal_ips_still_blocks_non_http_scheme(self):
484+
"""--allow-internal-ips relaxes only the private-IP check; the scheme
485+
lock stays on, so file:// is still refused and no request is made."""
486+
with patch("httpx.AsyncClient") as mock_client_class:
487+
mock_client = AsyncMock()
488+
mock_client.get = AsyncMock()
489+
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
490+
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
491+
492+
with pytest.raises(McpError):
493+
await fetch_url(
494+
"file:///etc/passwd",
495+
DEFAULT_USER_AGENT_AUTONOMOUS,
496+
allow_internal_ips=True,
497+
)
498+
499+
mock_client.get.assert_not_called()

0 commit comments

Comments
 (0)