#8577·skyvern

Blocked-host navigation gate only covers agent-initiated routes; page-initiated navigation, history back, and window.open tab adoption skip it

Author: AUTHENSORCreated Sep 16, 2026Updated Sep 16, 2026
LabelsbugAgent Navigationsecuritysecurity-sandbox

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:

  1. 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 only framenavigated listener 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 is BrowserNetworkEgressMonitor.unenrolled() at skyvern/webeye/browser_factory.py:1378, which installs no routes.
  2. History navigation. handle_go_back_action and handle_go_forward_action call page.go_back()/page.go_forward() with no destination validation (handler.py:10707, 10723).
  3. window.open popups. They are adopted as tabs by list_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 by capture_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).

  1. Start two loopback HTTP servers: port 47631 serves the pivot page, port 47632 serves an internal-only page containing the marker string.
  2. Call validate_navigation_destination and validate_fetch_url on http://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).
  3. 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's get_interactable_element_tree: the marker is present in the element tree.
  4. Navigate to a second pivot page, then page.go_back() (what handle_go_back_action does): the browser is back on the internal URL, marker again in the element tree.
  5. 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 (what build_open_tabs_context renders 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=abc with 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:

  1. 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).
  2. 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 of list_valid_pages so they never reach open_tabs_context or SWITCH_TAB.
  3. In handle_go_back_action / handle_go_forward_action, validate page.url after the history navigation and reset on refusal, as the reload/new-tab handlers already do for their targets.