Skip to content

Security: cloud browser hybrid routing bypasses pre-nav SSRF guard for IMDS endpoints #16234

@bisonbet

Description

@bisonbet

Summary

Commit 42c076d3 (feat(browser): auto-spawn local Chromium for LAN/localhost URLs in cloud mode) introduces a hybrid browser routing feature that unintentionally bypasses the pre-navigation SSRF safety check for the entire 169.254.0.0/16 link-local range, including cloud instance metadata endpoints (169.254.169.254).

Affected configuration

  • Cloud browser provider configured (Browserbase / Browser-Use / Firecrawl) — the default new-user path
  • browser.auto_local_for_private_urls: true (introduced default: True)

Both conditions are on by default, making this active for most cloud deployments out of the box.

Root cause

In tools/browser_tool.py, the pre-navigation SSRF guard was restructured to short-circuit when auto_local_this_nav is true:

if (
    not _is_local_backend()
    and not auto_local_this_nav      # <-- bypass condition added in 42c076d3
    and not _allow_private_urls()
    and not _is_safe_url(url)
):
    return json.dumps({"success": False, "error": "Blocked: ..."})

auto_local_this_nav is set to True whenever _url_is_private() classifies the URL as a private/loopback/LAN address. For 169.254.169.254, ipaddress.ip_address(...).is_link_local returns True, so _url_is_private()Trueauto_local_this_nav = True_is_safe_url() is never called.

The result: browser_navigate routes the request to a locally-spawned Chromium sidecar, which can reach http://169.254.169.254/latest/meta-data/ and similar IMDS endpoints. The agent can then read the full response via browser_snapshot, exfiltrating AWS/GCP/Azure instance credentials from within a cloud deployment.

The two classifiers diverge

_url_is_private() (new, in browser_tool.py) and is_safe_url() (pre-existing, in url_safety.py) check different things:

Check _url_is_private() is_safe_url()
169.254.0.0/16 link-local ✅ caught (is_link_local) ✅ caught (_ALWAYS_BLOCKED_NETWORKS)
DNS failure returns False (fall-through) returns Falseblocks (fail-closed)
100.64.0.0/10 CGNAT ✅ caught ❌ not checked
is_reserved ❌ not checked ✅ checked
is_multicast ❌ not checked ✅ checked

For 169.254.169.254, both classifiers catch it — but the problem is that _url_is_private() matching causes is_safe_url() to be skipped entirely rather than running it as a second gate.

Secondary finding

_url_is_private() relies on ipaddress.ip_address(...).is_private for the 172.16.0.0/12 range. This property only covers that range in Python ≥ 3.11 (it was expanded in bpo-40791). On Python 3.10 runtimes, 172.16.x.x addresses pass through to the cloud provider undetected.

Recommended fix

The routing decision (local vs. cloud) and the safety check are orthogonal concerns and should not be collapsed into the same conditional. The fix is to always call _is_safe_url() regardless of routing destination — or at minimum, preserve the _ALWAYS_BLOCKED_NETWORKS / _ALWAYS_BLOCKED_IPS check before routing to the local sidecar:

# Always run the safety check before routing anywhere
if (
    not _is_local_backend()
    and not _allow_private_urls()
    and not _is_safe_url(url)
):
    return json.dumps({"success": False, "error": "Blocked: ..."})

# Routing decision is separate
auto_local_this_nav = _url_is_private(url) and _cfg_auto_local_for_private_urls()

Workaround

Set browser.auto_local_for_private_urls: false in config, or avoid configuring a cloud browser provider in environments where IMDS access would be harmful.


Discovered via automated daily security review of HEAD..origin/main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Critical — data loss, security, crash looptool/browserBrowser automation (CDP, Playwright)type/securitySecurity vulnerability or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions