diff --git a/src/openjarvis/tools/web_search.py b/src/openjarvis/tools/web_search.py index 0b01f8cc..227d76aa 100644 --- a/src/openjarvis/tools/web_search.py +++ b/src/openjarvis/tools/web_search.py @@ -7,6 +7,7 @@ from typing import Any from openjarvis.core.registry import ToolRegistry from openjarvis.core.types import ToolResult +from openjarvis.security.ssrf import check_ssrf from openjarvis.tools._stubs import BaseTool, ToolSpec @@ -76,6 +77,9 @@ class WebSearchTool(BaseTool): import httpx url = WebSearchTool._normalize_url(url) + ssrf_error = check_ssrf(url) + if ssrf_error: + raise ValueError(ssrf_error) resp = httpx.get( url.strip(), follow_redirects=True, diff --git a/tests/tools/test_web_search.py b/tests/tools/test_web_search.py index 1fef8fb2..d5e6ce3e 100644 --- a/tests/tools/test_web_search.py +++ b/tests/tools/test_web_search.py @@ -205,10 +205,17 @@ class TestUrlNormalization: class TestUrlFetching: + def _mock_ssrf(self, monkeypatch): + """Stub out the SSRF check (requires Rust backend).""" + import openjarvis.tools.web_search as _ws + + monkeypatch.setattr(_ws, "check_ssrf", lambda url: None) + def test_fetch_url_success(self, monkeypatch): """Mocked HTTP GET returns HTML, stripped to text.""" import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = "

Hello world

" mock_resp.headers = {"content-type": "text/html"} @@ -221,6 +228,7 @@ class TestUrlFetching: def test_fetch_url_strips_scripts(self, monkeypatch): import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = ( "Content" @@ -236,6 +244,7 @@ class TestUrlFetching: def test_fetch_url_truncates_long_content(self, monkeypatch): import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = "

" + "x" * 10000 + "

" mock_resp.headers = {"content-type": "text/html"} @@ -249,6 +258,7 @@ class TestUrlFetching: def test_fetch_url_pdf_content_type(self, monkeypatch): import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = "%PDF-1.4 binary data" mock_resp.headers = {"content-type": "application/pdf"} @@ -261,10 +271,17 @@ class TestUrlFetching: class TestExecuteWithUrl: + def _mock_ssrf(self, monkeypatch): + """Stub out the SSRF check (requires Rust backend).""" + import openjarvis.tools.web_search as _ws + + monkeypatch.setattr(_ws, "check_ssrf", lambda url: None) + def test_execute_with_url_query(self, monkeypatch): """When query is a URL, fetch instead of search.""" import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = "Page content here" mock_resp.headers = {"content-type": "text/html"} @@ -281,6 +298,7 @@ class TestExecuteWithUrl: """When query contains a URL within text, detect and fetch it.""" import httpx + self._mock_ssrf(monkeypatch) mock_resp = MagicMock() mock_resp.text = "Article text" mock_resp.headers = {"content-type": "text/html"} @@ -294,10 +312,24 @@ class TestExecuteWithUrl: assert result.success is True assert result.metadata.get("mode") == "fetch" + def test_execute_url_ssrf_blocked(self, monkeypatch): + """SSRF check rejects unsafe URLs before any HTTP request.""" + import openjarvis.tools.web_search as _ws + + monkeypatch.setattr( + _ws, "check_ssrf", lambda url: "private IP blocked", + ) + + tool = WebSearchTool(api_key="test-key") + result = tool.execute(query="http://169.254.169.254/metadata") + assert result.success is False + assert "private IP blocked" in result.content + def test_execute_url_fetch_failure(self, monkeypatch): """URL fetch failure returns error result.""" import httpx + self._mock_ssrf(monkeypatch) monkeypatch.setattr( httpx, "get", MagicMock(side_effect=httpx.HTTPError("Connection failed")),