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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user