Repository navigation
security(browser): send_to_browser_vm and playwright_service bypass the SSRF/DNS-rebind guard the other browser paths enforce #13204
Description
Part of #12645 / #12756. Found while auditing the browser stacks for #12651.
The SSRF guard is not applied uniformly across the browser execution paths
AutoBot reaches the web through four browser/fetch entry points. They do not share a URL guard, and the two that an agent can steer are the weakest.
| path | guard |
|---|---|
content_reach/backends/browser.py |
ensure_public_url + ensure_robots_allowed (shared DNS-resolving guard) |
research_browser_manager.py |
DNS-rebind re-check after navigation (#13018) |
api/browser_mcp.py → send_to_browser_vm() |
none |
services/playwright_service.py |
none |
Detail
1. send_to_browser_vm() performs no URL validation.
api/browser_mcp.py does define is_url_allowed(), but it is called from exactly one place — the POST /mcp/navigate HTTP endpoint. The internal helper that actually drives the browser worker takes action + params and posts them straight through:
async def send_to_browser_vm(action, params, session_id=DEFAULT_BROWSER_SESSION_ID):
payload = {"action": action, "params": params, "session_id": ...}
async with await http_client.post(f"{BROWSER_VM_URL}/automation", json=payload, ...)That helper is on the agent path: chat_workflow/tool_handler.py::_web_search_via_browser_vm calls it with {"url": search_url}, and it is the final fallback of the web-search tool. A navigate to an internal address or a link-local metadata endpoint is not blocked here.
This matters more than a normal SSRF surface because the content the agent is reading is itself untrusted — the injection-to-navigation path is exactly what #12757 (IDPI / trust-boundary wrapping) is about.
2. is_url_allowed() is a regex allowlist, not a resolving guard.
Where it is applied it matches on the URL string only, so it cannot see where a hostname resolves — the DNS-rebinding class that #13018 fixed for the research browser. It also allowlists localhost, the loopback address and a hardcoded internal VM range, and still carries a placeholder entry (.*\.example\.com, commented "Example - add real domains").
Note it does not allowlist the search domain that _web_search_via_browser_vm navigates to — further evidence that path never passes through it.
3. services/playwright_service.py validates nothing.
capture_screenshot(url=...) and search_web(...) post caller-supplied URLs to the container with no check.
Why this belongs with #12651
#12651 is converging these stacks behind one canonical browser interface. The guard should be enforced in that interface, once, rather than re-added to three call sites that have already drifted apart — which is the whole argument for the convergence. Fixing it per-stack now would create a fourth thing to keep in sync.
Suggested sequencing:
- Land the canonical interface (tech-debt(backend): unify two Playwright browser stacks behind one interface #12651) with
ensure_public_url(DNS-resolving) enforced on every navigate/fetch primitive it exposes. - Migrate
send_to_browser_vmandplaywright_serviceonto it. - Retire
is_url_allowed's regex allowlist in favour of the shared guard, keeping any genuinely needed internal-host exceptions as an explicit, documented allowlist rather than a regex over the raw URL.
If #12651 slips, item 1 should still be done standalone for send_to_browser_vm, since that is the agent-reachable one.
Verification done
is_url_allowedcall sites: 1 (/mcp/navigateonly).url_safety/ensure_public_urlreferences:research_browser_manager.py2,content_reach/backends/browser.py3,services/playwright_service.py0,api/browser_mcp.py0._web_search_via_browser_vm→send_to_browser_vm("navigate", {"url": ...})confirmed inchat_workflow/tool_handler.py.
No exploit was attempted; this is a static read of the call graph.
Activity
docs(adr): ADR-009 — one canonical browser interface with execution b…
feat(browser): canonical browser interface + three backend wrappers, …
mrveiss commentedon Aug 2, 2026
Enumerated the call sites, as this issue and #13236 both said had to happen before the guard is enforced. The result de-risks it substantially.
Only one action carries a URL
send_to_browser_vm(action, params, session_id)is called with many actions. Counting whatparamsactually contains across every call site inapi/browser_mcp.py:So the guard surface is
navigate, not the whole helper. That is much narrower than "every call through an unvalidated transport".Where a URL actually reaches it
api/browser_mcp.py:708navigaterequest.url(HTTP endpoint)is_url_allowed(regex)chat_workflow/tool_handler.py:2885navigatechat_workflow/tool_handler.py:2210tool_name(dynamic)paramschat_workflow/tool_handler.py:2697evaluatetool_handler.py:2210is the one that matters. It forwards a dynamictool_namewith agent-suppliedparams— so when the agent calls the browsernavigatetool, the URL comes from the model and reaches the worker with no validation at all. That is the agent-reachable path, and it is the reason this is filed as security rather than tidiness.The question you actually have to answer: does anything legitimately navigate internally?
The existing allowlist permits internal targets, which is what made me flag that enforcing the guard might break real usage:
On the evidence, nothing in production uses those entries. Searching every non-test caller for a navigation to
localhost/127.0.0.1returns nothing. The internal entries look like leftovers, sitting beside an admitted placeholder (*.example.com, commented "add real domains").Two further signals that the allowlist is not load-bearing:
tool_handler.py:2885navigates to, yet that path works — because it never passes throughis_url_allowed;is_url_allowedguards exactly one call site out of the whole module.What I would change, given the above
ensure_public_url(DNS-resolving) onnavigate— via the canonical interface fortool_handler, which is ADR-009 step 4.is_url_allowed's regex with the same guard on/mcp/navigate, keeping an explicit, documented internal-host exception list — which on this evidence can start empty.The residual risk is not in production code but in the HTTP endpoint: a human or the frontend could be calling
/mcp/navigateagainst an internal address for diagnostics. That is not answerable from the codebase, and it is the one thing worth your confirmation before step 4 lands.Relates to #13236 (step 4 is what closes this), #12645.
fix(security): validate URLs at the browser transport, exempting the …
github-actions commentedon Aug 2, 2026
PR #13310 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
changelog(13204): capture_screenshot SSRF guard fragment (#13204)
security(browser): validate the URL in capture_screenshot (#13204)
mrveiss commentedon Sep 13, 2026
Post-merge closure audit: verified on
mainat 0abe0d4. This issue has no checkbox ACs, so this comment maps each stated problem to the fix.mainsend_to_browser_vm()performs no URL validationmain.api/browser_mcp.py:613_reject_non_public_urlcallsis_public_url_asyncat:624, andsend_to_browser_vm(:632) calls it at:680.is_url_allowed()is a regex allowlist, not a resolving guardservices/browser_url_guard.py:198is_url_allowednow decides throughclassify_url(:157), which calls the DNS-resolvingis_public_url_async(:179). A non-public address is admitted only for loopback or a fleet-registry host. The module docstring records that #13236 step 5 removed the regex allowlist.services/playwright_service.pyvalidates nothingcapture_screenshot(:308) checksis_public_url_async(url)at:325and returns "URL is not a public address" (:327) before any I/O. The test is inservices/playwright_service_test.py.search_web(:132) takes a query rather than a URL, so it has nothing to validate.The rest of the ADR-009 work, migrating the remaining callers onto the canonical interface, is tracked in #13236 (open).
The issue stays closed.