From 4612ee946404e1a397ab28bd98493b8283ebbdcc Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:01:55 +0300 Subject: [PATCH] security(browser): re-check private-network guard after browser_back navigation Every other content-returning browser tool entry point (browser_snapshot/vision/console/eval, and click/type/press via _blocked_private_page_action) re-checks window.location.href against the private/internal/cloud-metadata floor after the page could have changed -- because a redirect chain or client-side navigation can land on an address the initial browser_navigate preflight never saw. browser_back was the one navigation-triggering entry point missing this: it called _run_browser_command(..., "back", []) and returned the resulting URL straight to the model with no re-check. On a cloud/CDP (non-local) backend, if browser history contains a private/internal address (e.g. a prior redirect touched an internal host), browser_back would navigate the live browser there and hand the URL back to the model with no guard -- the exact class of gap the private-page guard exists to close, just on the one entry point it hadn't reached yet. Re-check happens after the navigation succeeds (not before, unlike click/type/press) since it's the resulting page -- not the one being left -- whose safety matters. A failed back navigation (no history) skips the check entirely since nothing changed. Verified live: the new regression test fails (returns the private URL instead of a blocked payload) on the pre-fix code and passes after. --- .../test_browser_private_page_action_guard.py | 97 +++++++++++++++++++ tools/browser_tool.py | 19 ++++ 2 files changed, 116 insertions(+) diff --git a/tests/tools/test_browser_private_page_action_guard.py b/tests/tools/test_browser_private_page_action_guard.py index f0d906e62..ff01d26b0 100644 --- a/tests/tools/test_browser_private_page_action_guard.py +++ b/tests/tools/test_browser_private_page_action_guard.py @@ -104,3 +104,100 @@ def test_camofox_short_circuits_before_guard(monkeypatch): out = json.loads(browser_tool.browser_click("@e1", task_id="task-1")) assert out == {"success": True, "camofox": True} + + +# --------------------------------------------------------------------------- +# browser_back — unlike click/type/press (check current page BEFORE acting), +# going back IS the navigation: the guard must fire AFTER _run_browser_command +# reports success, checking the page it just landed on, not the page it left. +# --------------------------------------------------------------------------- + + +def test_browser_back_blocks_when_landed_page_is_private(monkeypatch): + """Browser history can land on a private/internal address the initial + browser_navigate preflight never saw — the same class of gap already + closed for browser_snapshot/vision/console/eval and click/type/press.""" + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr(browser_tool, "_current_page_private_url", lambda task_id: PRIVATE_URL) + monkeypatch.setattr( + browser_tool, "_run_browser_command", + lambda task_id, command, args: {"success": True, "data": {"url": PRIVATE_URL}}, + ) + + out = json.loads(browser_tool.browser_back(task_id="task-1")) + + assert out["success"] is False + assert PRIVATE_URL in out["error"] + assert "private or internal address" in out["error"] + # The blocked payload must not itself leak the raw URL as a "url" field + # the way the success payload does. + assert "url" not in out + + +def test_browser_back_returns_url_when_landed_page_is_public(monkeypatch): + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr(browser_tool, "_current_page_private_url", lambda task_id: None) + monkeypatch.setattr( + browser_tool, "_run_browser_command", + lambda task_id, command, args: {"success": True, "data": {"url": "https://example.com/"}}, + ) + + out = json.loads(browser_tool.browser_back(task_id="task-1")) + + assert out == {"success": True, "url": "https://example.com/"} + + +def test_browser_back_guard_inactive_does_not_probe(monkeypatch): + """When the SSRF guard is inactive (local backend), back navigation must + proceed without even probing the landed page URL.""" + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: False) + + def fail_probe(task_id): + raise AssertionError("_current_page_private_url must not be probed when guard inactive") + + monkeypatch.setattr(browser_tool, "_current_page_private_url", fail_probe) + monkeypatch.setattr( + browser_tool, "_run_browser_command", + lambda task_id, command, args: {"success": True, "data": {"url": "https://example.com/"}}, + ) + + out = json.loads(browser_tool.browser_back(task_id="task-1")) + + assert out == {"success": True, "url": "https://example.com/"} + + +def test_browser_back_failed_navigation_does_not_probe(monkeypatch): + """No page change happened, so there is nothing new to check — the guard + must not fire (or probe) on a failed back navigation.""" + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: True) + + def fail_probe(task_id): + raise AssertionError("must not probe when the back navigation itself failed") + + monkeypatch.setattr(browser_tool, "_current_page_private_url", fail_probe) + monkeypatch.setattr( + browser_tool, "_run_browser_command", + lambda task_id, command, args: {"success": False, "error": "no history"}, + ) + + out = json.loads(browser_tool.browser_back(task_id="task-1")) + + assert out == {"success": False, "error": "no history"} + + +def test_browser_back_camofox_short_circuits_before_guard(monkeypatch): + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True) + + def fail_guard(task_id): + raise AssertionError("guard must not run in camofox mode") + + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", fail_guard) + monkeypatch.setattr(browser_tool, "_current_page_private_url", fail_guard) + + import tools.browser_camofox as camofox + + monkeypatch.setattr(camofox, "camofox_back", lambda task_id: '{"success": true, "camofox": true}') + + out = json.loads(browser_tool.browser_back(task_id="task-1")) + + assert out == {"success": True, "camofox": True} diff --git a/tools/browser_tool.py b/tools/browser_tool.py index a108c2ebf..3604450c9 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -3182,6 +3182,25 @@ def browser_back(task_id: Optional[str] = None) -> str: result = _run_browser_command(effective_task_id, "back", []) if result.get("success"): + # Browser history can land on a private/internal/cloud-metadata + # address that the browser_navigate preflight never saw (e.g. a + # redirect chain from an earlier legitimate navigation touched an + # internal host, or client-side history was otherwise manipulated). + # Re-check post-navigation, matching every other content-returning + # entry point (browser_snapshot/vision/console/eval, and click/type/ + # press via _blocked_private_page_action) — the floor must fire for + # every backend, not just the initial navigate. + if _eval_ssrf_guard_active(effective_task_id): + _blocked_url = _current_page_private_url(effective_task_id) + if _blocked_url: + return json.dumps({ + "success": False, + "error": ( + "Blocked: page URL targets a private or internal address " + f"({_blocked_url}). Browser history navigation (back) " + "landed on this address." + ), + }, ensure_ascii=False) data = result.get("data", {}) response = { "success": True,