allowed_domains / prohibited_domains only gate agent-initiated navigation; every other route to a page skips the gate
allowed_domains / prohibited_domains only gate agent-initiated navigation; every page-initiated route skips the check
Severity: high (complete bypass of the documented domain lockdown on all navigation routes that a visited page can trigger)
Affected versions: browser-use 0.13.10 (pyproject version at main branch commit 843819cb8131e1370948d381ede9be7f8366ddc4, dated 2026-09-13); the watchdog architecture is older, earlier versions are likely affected too but were not verified.
Summary
BrowserProfile.allowed_domains and prohibited_domains are enforced in exactly one place, SecurityWatchdog._is_url_allowed (browser_use/browser/watchdogs/security_watchdog.py:176). The watchdog only runs on three events: NavigateToUrlEvent, NavigationCompleteEvent and TabCreatedEvent. Of all the ways a browser tab can end up on a new origin, only one (the agent calling the navigate_to / search tool) passes through those events:
Page-initiated navigation. A visited page that runs
location.href = 'https://evil.example/'(or a meta refresh, or a JS redirect) is surfaced by Chrome solely as aTarget.targetInfoChangedCDP event.SessionManager._handle_target_info_changed(browser_use/browser/session_manager.py:510-528) updatestarget.urland does nothing else. No event is dispatched, the gate never runs.Redirects after an allowed navigation. The post-navigation handler comment says it "catches redirects to blocked domains" (security_watchdog.py:51), but the event is built with the REQUESTED URL:
NavigationCompleteEvent(target_id=target_id, url=event.url, ...)(browser_use/browser/session.py:986-992). Re-validating the original string is a tautology; the URL the browser actually landed on is never checked on this path.window.open / target=_blank. No
Target.targetCreatedhandler is registered anywhere in the package; the only registered Target handlers areattachedToTarget(session_manager.py:129) andtargetInfoChanged(session_manager.py:131). Page-opened tabs arrive viaTarget.attachedToTarget(handled at session_manager.py:402), which dispatches noTabCreatedEvent; the onlyTabCreatedEventdispatch sites are agent-created blank tabs, initial tabs at connect, and crash recovery (session.py:944, session.py:1157, session.py:1991, session_manager.py:708, session_manager.py:762).Cross-origin iframes (OOPIF), history navigation, tab switching. Auto-attach covers iframe targets (session_manager.py:426-435) with no frame URL check, and their content is merged into the DOM snapshot (browser_use/dom/service.py:361-399).
go_backcallsPage.navigateToHistoryEntrydirectly (browser_use/browser/watchdogs/default_action_watchdog.py:2386-2390). TheSwitchTabEventhandler (session.py:1139) activates any existing page target with no check.
The only post-hoc mitigation is DownloadsWatchdog.on_BrowserStateRequestEvent (browser_use/browser/watchdogs/downloads_watchdog.py:219-249), which on each state capture dispatches (without awaiting, line 241) a NavigationCompleteEvent carrying the CURRENT page URL; the security watchdog then navigates the tab to about:blank. This happens only on the NEXT state poll: by then the disallowed document has already loaded, executed script, and sent cookies, and its URL, DOM and screenshot for the current step are captured concurrently into the model context.
Reproduction (logic level, no browser needed)
Run the script below against a checkout of the pinned commit (dependencies: the package's own, only bubus is imported beyond the standard library; only a stub browser_session reference is substituted, every browser_use class used is the real one):
import asyncio, logging
from types import SimpleNamespace
logging.basicConfig(level=logging.CRITICAL)
from bubus import EventBus
from browser_use.browser import BrowserProfile
from browser_use.browser.events import NavigateToUrlEvent
from browser_use.browser.session import Target
from browser_use.browser.session_manager import SessionManager
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
fake_logger = logging.getLogger('repro')
fake_logger.setLevel(logging.CRITICAL)
async def main() -> None:
profile = BrowserProfile(
allowed_domains=['good.test'],
prohibited_domains=['mail.example.com'],
)
bus = EventBus(name='repro')
sm = SessionManager(SimpleNamespace(logger=fake_logger))
sm._targets['T1'] = Target(target_id='T1', target_type='page', url='https://good.test/', title='good')
session_stub = SimpleNamespace(browser_profile=profile, event_bus=bus, logger=fake_logger)
wd = SecurityWatchdog.model_construct(event_bus=bus, browser_session=session_stub)
calls: list[str] = []
orig = wd._is_url_allowed
wd._is_url_allowed = lambda url: (calls.append(url), orig(url))[1]
try:
await wd.on_NavigateToUrlEvent(NavigateToUrlEvent(url='https://evil.test/pwned', new_tab=False))
print('1) agent navigate_to https://evil.test/pwned -> NOT BLOCKED (unexpected)')
except ValueError as e:
print(f'1) agent navigate_to https://evil.test/pwned -> blocked: {e}')
print(f' gate evaluations: {calls}')
calls.clear()
await sm._handle_target_info_changed(
{'targetInfo': {'targetId': 'T1', 'url': 'https://evil.test/pwned', 'title': 'evil'}}
)
t = sm._targets['T1']
print(f'2) after targetInfoChanged -> target.url={t.url!r}, gate evaluations={len(calls)}')
calls.clear()
await sm._handle_target_info_changed(
{'targetInfo': {'targetId': 'T1', 'url': 'https://mail.example.com/inbox', 'title': 'mail'}}
)
t = sm._targets['T1']
print(f'3) after targetInfoChanged -> target.url={t.url!r}, gate evaluations={len(calls)}')
calls.clear()
from browser_use.browser.events import NavigationCompleteEvent
await wd.on_NavigationCompleteEvent(NavigationCompleteEvent(target_id='T1', url='https://good.test/hop'))
print(f'4) post-navigation re-check saw only: {calls} (the tab may really be on https://evil.test/pwned)')
loop = asyncio.new_event_loop()
loop.run_until_complete(main())
loop.close()
Actual output (pinned commit)
1) agent navigate_to https://evil.test/pwned -> blocked: Navigation to https://evil.test/pwned blocked by security policy
gate evaluations: ['https://evil.test/pwned']
2) after targetInfoChanged -> target.url='https://evil.test/pwned', gate evaluations=0
3) after targetInfoChanged -> target.url='https://mail.example.com/inbox', gate evaluations=0
4) post-navigation re-check saw only: ['https://good.test/hop'] (the tab may really be on https://evil.test/pwned)
Expected vs actual
- Expected: with
allowed_domains=['good.test']andprohibited_domains=['mail.example.com'], the session should never come to rest onevil.testormail.example.com, regardless of which layer initiated the navigation. - Actual: only the agent
navigate_toroute is gated (line 1). The page-initiated route (lines 2 and 3) flips the live target URL to the disallowed / prohibited origin with zero gate evaluations, zeroPage.navigatecalls and zero dispatched events. The post-navigation re-check (line 4) validates the originally requested URL, so a redirect chain fromgood.test/hoptoevil.test/pwnedis never detected at the point where the redirect is observable.
Impact
The library itself positions the allowlist as the protective control for secrets, e.g. browser_use/agent/service.py:538-543:
Agent(sensitive_data=...) was provided but Browser(allowed_domains=[...]) is not locked down!
If the agent visits a malicious website and encounters a prompt-injection attack, your sensitive_data may be exposed!
With prohibited_domains=['mail.google.com'] (a documented use: keep the agent away from the user's email), any visited page can land the session on the prohibited origin via location.href, an iframe, or a redirect from an allowed URL. The page loads with the user's real cookies (the default profile is a persistent user data dir, and default launch flags disable ThirdPartyStoragePartitioning, browser_use/browser/profile.py:74), its content is rendered into the step's DOM snapshot and screenshot, and both are shipped to the LLM provider as part of the user message (browser_use/agent/prompts.py:236, prompts.py:297, prompts.py:455-470). A crafted page on an allowed origin therefore reaches the "disallowed document in context" outcome without ever touching the one gated route.
Suggested fix
Enforce the allowlist at the CDP event level, not at the tool level:
- Run
_is_url_allowedonTarget.targetInfoChanged(this is the final URL of every navigation, including redirects) and act on disallowed targets there (close, detach, or bounce to about:blank). - On attach of any new page or iframe target (
Target.attachedToTarget), check the target URL the same way, which also coverswindow.openpopups. - Validate the URL after navigation completes by reading the live target URL rather than the requested one, replacing the current tautological re-check.
- Gate history navigation (
Page.navigateToHistoryEntry) and tab focus (SwitchTabEvent).
Until then, the docs should not present allowed_domains as a lockdown mechanism for sensitive data.
Source: browser-use/browser-use