From a75f056969db99b3c7a97a3aaa0fe88627706d70 Mon Sep 17 00:00:00 2001 From: dieutx Date: Tue, 24 Mar 2026 06:52:16 +0700 Subject: [PATCH 1/2] fix(security): validate URL scheme and block SSRF in browser_navigate browser_navigate passes URLs directly to the browser subprocess with no scheme validation. file://, javascript:, and data: URLs can be used to read local files or execute code in the browser context. Private network addresses (localhost, metadata endpoints) are also reachable. Restrict to http/https schemes and add SSRF check via url_safety before the browser command is dispatched. --- tests/tools/test_browser_url_validation.py | 48 ++++++++++++++++++++++ tools/browser_tool.py | 20 +++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/tools/test_browser_url_validation.py diff --git a/tests/tools/test_browser_url_validation.py b/tests/tools/test_browser_url_validation.py new file mode 100644 index 0000000000000..18e1a182e9212 --- /dev/null +++ b/tests/tools/test_browser_url_validation.py @@ -0,0 +1,48 @@ +"""Tests for browser_navigate URL scheme and SSRF validation.""" + +import json +from unittest.mock import patch + +from tools.browser_tool import browser_navigate + + +class TestBrowserUrlSchemeValidation: + """browser_navigate must reject non-http(s) schemes.""" + + def test_file_scheme_blocked(self): + result = json.loads(browser_navigate("file:///etc/shadow")) + assert result["success"] is False + assert "scheme" in result["error"].lower() + + def test_javascript_scheme_blocked(self): + result = json.loads(browser_navigate("javascript:alert(1)")) + assert result["success"] is False + assert "scheme" in result["error"].lower() + + def test_data_scheme_blocked(self): + result = json.loads(browser_navigate("data:text/html,

pwned

")) + assert result["success"] is False + assert "scheme" in result["error"].lower() + + def test_ftp_scheme_blocked(self): + result = json.loads(browser_navigate("ftp://evil.com/malware")) + assert result["success"] is False + assert "scheme" in result["error"].lower() + + def test_http_scheme_allowed(self): + """http URLs should pass scheme check (may fail later on browser connect).""" + with patch("tools.browser_tool._run_browser_command", + return_value={"success": True, "data": {"title": "Test", "url": "http://example.com"}}): + with patch("tools.browser_tool.check_website_access", return_value=None): + with patch("tools.browser_tool._get_session_info", return_value={"_first_nav": False}): + result = json.loads(browser_navigate("http://example.com")) + assert result.get("success") is True or "error" not in result or "scheme" not in result.get("error", "") + + def test_https_scheme_allowed(self): + """https URLs should pass scheme check.""" + with patch("tools.browser_tool._run_browser_command", + return_value={"success": True, "data": {"title": "Test", "url": "https://example.com"}}): + with patch("tools.browser_tool.check_website_access", return_value=None): + with patch("tools.browser_tool._get_session_info", return_value={"_first_nav": False}): + result = json.loads(browser_navigate("https://example.com")) + assert "scheme" not in result.get("error", "") diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 5aea75f552095..bd3278c371b65 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -947,6 +947,26 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: Returns: JSON string with navigation result (includes stealth features info on first nav) """ + # Restrict to http/https — block file://, javascript:, data:, etc. + from urllib.parse import urlparse as _urlparse + _parsed = _urlparse(url) + if _parsed.scheme not in ("http", "https", ""): + return json.dumps({ + "success": False, + "error": f"Unsupported URL scheme '{_parsed.scheme}'. Only http and https are allowed.", + }) + + # SSRF protection — block private/internal addresses + try: + from tools.url_safety import is_safe_url + if not is_safe_url(url): + return json.dumps({ + "success": False, + "error": "Blocked: URL targets a private or internal network address.", + }) + except ImportError: + pass # url_safety module not available — skip check + # Website policy check — block before navigating blocked = check_website_access(url) if blocked: From daf6ce86f21ff39be1fab62db6070ef1c4bcc61c Mon Sep 17 00:00:00 2001 From: dieutx Date: Tue, 24 Mar 2026 07:01:50 +0700 Subject: [PATCH 2/2] fix(security): make SSRF check mockable and fix website policy test Move url_safety import to module level as _is_browser_url_safe so tests can mock it. Update test_browser_navigate_returns_policy_block to bypass SSRF check so the website policy path is tested correctly. --- tests/tools/test_website_policy.py | 2 ++ tools/browser_tool.py | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 52618a1d63cb2..6aaec73e7a6c3 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -292,6 +292,8 @@ def test_check_website_access_blocks_scheme_less_urls(tmp_path): def test_browser_navigate_returns_policy_block(monkeypatch): from tools import browser_tool + # Allow the URL past SSRF check so the website policy check is reached + monkeypatch.setattr(browser_tool, "_is_browser_url_safe", lambda url: True) monkeypatch.setattr( browser_tool, "check_website_access", diff --git a/tools/browser_tool.py b/tools/browser_tool.py index bd3278c371b65..22f268c795247 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -70,6 +70,11 @@ from tools.website_policy import check_website_access except Exception: check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable + +try: + from tools.url_safety import is_safe_url as _is_browser_url_safe +except Exception: + _is_browser_url_safe = lambda url: True # noqa: E731 — fail-open if module unavailable from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider @@ -957,15 +962,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: }) # SSRF protection — block private/internal addresses - try: - from tools.url_safety import is_safe_url - if not is_safe_url(url): - return json.dumps({ - "success": False, - "error": "Blocked: URL targets a private or internal network address.", - }) - except ImportError: - pass # url_safety module not available — skip check + if not _is_browser_url_safe(url): + return json.dumps({ + "success": False, + "error": "Blocked: URL targets a private or internal network address.", + }) # Website policy check — block before navigating blocked = check_website_access(url)