fix(web): run URL SSRF checks off the event loop in async paths
Add async_is_safe_url() wrapping is_safe_url via asyncio.to_thread, and route all async SSRF call sites through it: web_extract_tool, the vision/video preflight checks, and both download redirect guards. socket.getaddrinfo blocks; calling it inline from async tool paths froze the event loop for the duration of DNS resolution. vision_tools: split _validate_image_url into _image_url_shape_ok (no DNS) + sync _validate_image_url (for sync callers/tests) + async _validate_image_url_async. Widened beyond the original PR #3691 to sibling async sites that also blocked the loop (second redirect guard, video preflight). Salvage of #3691 by @Kewe63 — surgically re-applied onto current main because the original branch was too stale to cherry-pick cleanly (would have reverted the web_crawl_tool refactor). Co-authored-by: Kewe63 <kewe.3217@gmail.com>
This commit is contained in:
@@ -297,7 +297,7 @@ class TestErrorLoggingExcInfo:
|
||||
async def test_analysis_error_logs_exc_info(self, caplog):
|
||||
"""When vision_analyze_tool encounters an error, it should log with exc_info."""
|
||||
with (
|
||||
patch("tools.vision_tools._validate_image_url", return_value=True),
|
||||
patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
|
||||
patch(
|
||||
"tools.vision_tools._download_image",
|
||||
new_callable=AsyncMock,
|
||||
@@ -329,7 +329,7 @@ class TestErrorLoggingExcInfo:
|
||||
return dest
|
||||
|
||||
with (
|
||||
patch("tools.vision_tools._validate_image_url", return_value=True),
|
||||
patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
|
||||
patch("tools.vision_tools._download_image", side_effect=fake_download),
|
||||
patch(
|
||||
"tools.vision_tools._image_to_base64_data_url",
|
||||
@@ -451,7 +451,7 @@ class TestVisionSafetyGuards:
|
||||
|
||||
with (
|
||||
patch("tools.vision_tools.check_website_access", return_value=blocked),
|
||||
patch("tools.vision_tools._validate_image_url", return_value=True),
|
||||
patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
|
||||
patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download,
|
||||
):
|
||||
result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe"))
|
||||
@@ -549,7 +549,9 @@ class TestTildeExpansion:
|
||||
img = fake_home / "test_image.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)
|
||||
|
||||
# Windows expanduser() prefers USERPROFILE over HOME; POSIX uses HOME.
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
@@ -580,6 +582,7 @@ class TestTildeExpansion:
|
||||
fake_home = tmp_path / "fakehome"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
||||
|
||||
result = await vision_analyze_tool(
|
||||
"~/nonexistent.png", "describe this", "test/model"
|
||||
|
||||
Reference in New Issue
Block a user