Blocked-host navigation gate only covers agent-initiated routes; page-initiated navigation, history back, and window.open tab adoption skip it
Blocked-host navigation gate only covers agent-initiated routes; page-initiated navigation, history back, and window.open tab adoption skip it
Severity: serious (browser-plane SSRF boundary bypass; internal response content reaches the LLM prompt, extracted output, and screenshot artifacts)
Affected: skyvern-ai/skyvern at commit 73fecfa5e79f05aa843cf060db9d8e94f18ac1f4 (OSS self-hosted deployments where the backend launches the browser with direct network reachability; cloud runs behind the run proxy already refuse internal hosts at the proxy).
Summary
validate_navigation_destination (skyvern/webeye/navigation.py:43) fails navigation closed unless the target is a public http(s) destination, and it is enforced on every agent-initiated route: the pre-dispatch check in navigate_with_retry (navigation.py:258), hop-by-hop redirect revalidation (navigation.py:81), the GOTO_URL and NEW_TAB action handlers (skyvern/webeye/actions/handler.py:10692, :10802), the parse-time refusal of LLM-issued GOTO_URL/NEW_TAB (skyvern/webeye/actions/parse_actions.py:302, :328), and the streaming CDP input path (skyvern/forge/sdk/routes/streaming/cdp_input.py:385, :449).
Routes the gate never sees:
- Page-initiated main-frame navigation. After an action batch, the next step scrapes whatever document the page moved itself to (JS
location.href, meta refresh, form submit). The onlyframenavigatedlistener in the tree is the egress monitor's epoch counter (skyvern/forge/sdk/browser_network_egress_monitor.py:385);install()there has no production callers, and the only production construction of a monitor isBrowserNetworkEgressMonitor.unenrolled()atskyvern/webeye/browser_factory.py:1378, which installs no routes. - History navigation.
handle_go_back_actionandhandle_go_forward_actioncallpage.go_back()/page.go_forward()with no destination validation (handler.py:10707, 10723). window.openpopups. They are adopted as tabs bylist_valid_pages(skyvern/webeye/real_browser_state.py:519), their URLs and titles are rendered into the prompt with no host filter (build_open_tabs_context,skyvern/webeye/utils/page.py:256), they are captured bycapture_open_tab_screenshots(page.py:285), and SWITCH_TAB switches to any tab with no URL check (handler.py:10826).
Consequence: a page the agent visits can pivot the browser onto internal-only targets (loopback services, RFC1918 panels, cloud metadata) through those routes. The response bodies then flow into the element tree that is the prompt input (skyvern/forge/agent.py:6920), into extracted_information and action reasoning returned through the API and webhooks, and into screenshot artifacts. This is the browser-plane counterpart of the workflow-plane SSRF already tracked in issues 6537, 6915, and 7132.
Reproduction
The differential below runs skyvern's own validators against internal targets, then drives a real chromium through the same playwright API skyvern uses: a page the browser already loaded pivots to an internal-only HTTP service via JS navigation, history back, and window.open, and the internal service's body appears in the element tree skyvern's scrape builds for the prompt. Loopback stands in for the internal network; the internal page embeds a unique fixed marker string in its body so its arrival in the prompt input is unambiguous; the LLM call is not made (the element tree is the documented prompt input).
- Start two loopback HTTP servers: port 47631 serves the pivot page, port 47632 serves an internal-only page containing the marker string.
- Call
validate_navigation_destinationandvalidate_fetch_urlonhttp://127.0.0.1:47632/admin/secret,http://localhost:47632/admin/secret,http://169.254.169.254/latest/meta-data/iam/security-credentials/,http://10.9.9.9/internal,http://192.168.20.20/internal,http://[::1]/internal. All six are refused (BlockedNavigationDestination / BlockedHost), and a GOTO_URL action for the internal target is refused at parse (NullAction). - Load the pivot page in chromium; it runs
setTimeout(function() { location.href = "http://127.0.0.1:47632/admin/secret?token=abc"; }, 150). The navigation lands. Scrape with skyvern'sget_interactable_element_tree: the marker is present in the element tree. - Navigate to a second pivot page, then
page.go_back()(whathandle_go_back_actiondoes): the browser is back on the internal URL, marker again in the element tree. - Load a pivot page that runs
window.open("http://127.0.0.1:47632/admin/secret?via=window_open", "_blank"). The popup is adopted into the context's tab list (whatbuild_open_tabs_contextrenders to the prompt) and its content scrapes into the element tree.
Observed (deterministic across double runs):
- Part A: every internal target refused on both agent validators; GOTO_URL parse refusal logged as "GOTO_URL action targets a blocked host".
- Part B: landed_url
http://127.0.0.1:47632/admin/secret?token=abcwith marker_in_prompt_element_tree true; go_back re-lands with marker true; tab_urls contains the internal URL with popup marker true.
Expected: page-initiated navigation, history re-navigation, and adopted popups respect the same internal-host boundary the agent routes enforce, or are refused/rounded at the scrape boundary. Actual: only agent-initiated routes consult the gate.
Impact
In self-hosted deployments the browser runs where the backend runs, with direct network access. A visited page (or any third-party script it loads) can make the agent's browser fetch internal endpoints and get the bodies into (a) the LLM prompt as element text, disclosed to the configured LLM provider, (b) extracted_information and reasoning returned through the API and workflow webhooks, and (c) per-run screenshot artifacts. Non-blind SSRF from the browser vantage, using credentials and session state the browser already holds.
Recommended fix
Extend the boundary to the routes that currently skip it, ideally at one chokepoint each:
- After every action batch and before scraping, validate the working page's current URL with
validate_navigation_destination; on refusal, reset to about:blank and surface a blocked-destination failure (mirrors the redirect-chain refusal path in navigation.py:96). - Validate adopted popups at the
context.on("page")boundary and close or freeze pages whose initial URL is not a public http(s) destination; alternatively filter them out oflist_valid_pagesso they never reach open_tabs_context or SWITCH_TAB. - In
handle_go_back_action/handle_go_forward_action, validatepage.urlafter the history navigation and reset on refusal, as the reload/new-tab handlers already do for their targets.
Source: Skyvern-AI/skyvern