From ab548a9b5e4af6914f6318f6cc07b8cdf2ff3ed1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:16:57 -0700 Subject: [PATCH] fix(security): add SSRF protection to browser_navigate (#3058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): add SSRF protection to browser_navigate browser_navigate() only checked the website blocklist policy but did not call is_safe_url() to block private/internal addresses. This allowed the agent to navigate to localhost, cloud metadata endpoints (169.254.169.254), and private network IPs via the browser. web_tools and vision_tools already had this check. Added the same is_safe_url() pre-flight validation before the blocklist check in browser_navigate(). * fix: move SSRF import to module level, fix policy test mock Move is_safe_url import to module level so it can be monkeypatched in tests. Update test_browser_navigate_returns_policy_block to mock _is_safe_url so the SSRF check passes and the policy check is reached. * fix(security): harden browser SSRF protection Follow-up to cherry-picked PR #3041: 1. Fail-closed fallback: if url_safety module can't import, block all URLs instead of allowing all. Security guards should never fail-open. 2. Post-redirect SSRF check: after navigation, verify the final URL isn't a private/internal address. If a public URL redirected to 169.254.169.254 or localhost, navigate to about:blank and return an error — prevents the model from reading internal content via subsequent browser_snapshot calls. --------- Co-authored-by: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> --- tests/tools/test_website_policy.py | 2 ++ tools/browser_tool.py | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 52618a1d6..4312d970e 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 SSRF check to pass so the policy check is reached + monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) monkeypatch.setattr( browser_tool, "check_website_access", diff --git a/tools/browser_tool.py b/tools/browser_tool.py index a3e35570c..a497efc91 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -70,6 +70,11 @@ try: 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_safe_url +except Exception: + _is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider @@ -1025,6 +1030,13 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: Returns: JSON string with navigation result (includes stealth features info on first nav) """ + # SSRF protection — block private/internal addresses before navigating + if not _is_safe_url(url): + return json.dumps({ + "success": False, + "error": "Blocked: URL targets a private or internal address", + }) + # Website policy check — block before navigating blocked = check_website_access(url) if blocked: @@ -1052,7 +1064,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: data = result.get("data", {}) title = data.get("title", "") final_url = data.get("url", url) - + + # Post-redirect SSRF check — if the browser followed a redirect to a + # private/internal address, block the result so the model can't read + # internal content via subsequent browser_snapshot calls. + if final_url and final_url != url and not _is_safe_url(final_url): + # Navigate away to a blank page to prevent snapshot leaks + _run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10) + return json.dumps({ + "success": False, + "error": f"Blocked: redirect landed on a private/internal address", + }) + response = { "success": True, "url": final_url,