fix(security): add SSRF protection to browser_navigate (#3058)
* 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>
This commit is contained in:
@@ -292,6 +292,8 @@ def test_check_website_access_blocks_scheme_less_urls(tmp_path):
|
|||||||
def test_browser_navigate_returns_policy_block(monkeypatch):
|
def test_browser_navigate_returns_policy_block(monkeypatch):
|
||||||
from tools import browser_tool
|
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(
|
monkeypatch.setattr(
|
||||||
browser_tool,
|
browser_tool,
|
||||||
"check_website_access",
|
"check_website_access",
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ try:
|
|||||||
from tools.website_policy import check_website_access
|
from tools.website_policy import check_website_access
|
||||||
except Exception:
|
except Exception:
|
||||||
check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable
|
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.base import CloudBrowserProvider
|
||||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||||
@@ -1025,6 +1030,13 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON string with navigation result (includes stealth features info on first nav)
|
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
|
# Website policy check — block before navigating
|
||||||
blocked = check_website_access(url)
|
blocked = check_website_access(url)
|
||||||
if blocked:
|
if blocked:
|
||||||
@@ -1052,7 +1064,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
|||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
title = data.get("title", "")
|
title = data.get("title", "")
|
||||||
final_url = data.get("url", url)
|
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 = {
|
response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"url": final_url,
|
"url": final_url,
|
||||||
|
|||||||
Reference in New Issue
Block a user