Domain control plane: scheme-qualified allowed_domains entries are prefix-matched; default profile grants clipboardReadWrite to every origin
Domain control plane: scheme-qualified allowed_domains entries are prefix-matched, and the default profile grants clipboardReadWrite to all origins
Severity: high (part 1 is a complete allowlist bypass for the documented scheme-qualified format; part 2 is a cross-origin clipboard exposure on the default path)
Affected versions: browser-use 0.13.10 (pyproject version at main branch commit 843819cb8131e1370948d381ede9be7f8366ddc4, dated 2026-09-13); both code paths are older than this commit but earlier versions were not verified.
Part 1: scheme-qualified allowed_domains entries are prefix-matched
Mechanism
SecurityWatchdog._is_url_match (browser_use/browser/watchdogs/security_watchdog.py:252) treats allowlist patterns that contain :// as string prefixes (security_watchdog.py:282-287):
else:
# Exact match
if '://' in pattern:
# Full URL pattern
if url.startswith(pattern):
return True
BrowserProfile.allowed_domains documents this exact format as valid (browser_use/browser/profile.py:628-631):
List of allowed domains for navigation e.g. ["*.google.com", "https://example.com", "chrome-extension://*"].
A user who follows the docstring and writes allowed_domains=['https://good.test'] gets url.startswith() semantics: https://good.test.evil.com/pwned is allowed, because the URL string starts with the pattern. An attacker who controls evil.com simply creates the host good.test.evil.com.
The existing security tests (tests/ci/security/test_domain_filtering.py) cover userinfo bypasses and glob edge cases, but no test covers a hostname that suffix-extends a scheme-qualified pattern, which is why this has gone unnoticed.
Impact
Complete bypass of the allowlist for any deployment that uses the documented scheme-qualified format: the gate holds only as long as no attacker-controlled hostname happens to extend an allowed pattern string. Combined with the navigation-route gaps (page-initiated navigation, iframes, redirects, filed separately), this is a second, independent way the shipped domain control fails open.
Part 2: default profile grants clipboardReadWrite to all origins
Mechanism
BrowserProfile ships clipboard permission enabled by default (browser_use/browser/profile.py:363-368):
permissions: list[str] = Field(
default_factory=lambda: ['clipboardReadWrite', 'notifications'],
description='Browser permissions to grant (CDP Browser.grantPermissions).',
# clipboardReadWrite is for google sheets and pyperclip automations
# notifications are to avoid browser fingerprinting
)
On every browser connect, PermissionsWatchdog grants that list with no origin restriction (browser_use/browser/watchdogs/permissions_watchdog.py:23-42):
# Grant permissions using CDP Browser.grantPermissions
# origin=None means grant to all origins
# Browser domain commands don't use session_id
await self.browser_session.cdp_client.send.Browser.grantPermissions(
params={'permissions': permissions}
)
Because the origin parameter is omitted, the grant applies to every origin the browser will ever load, not just the automation targets.
Impact
In the default headful configuration the browser is the user's desktop browser session and shares the OS clipboard. navigator.clipboard.readText() normally requires a permission prompt plus document focus; with the permission pre-granted to all origins, any page the agent visits can read the clipboard silently (this is the same mechanism Playwright documents for grantPermissions(['clipboard-read']) in tests). Clipboards routinely hold passwords, one-time codes and API keys, so a single visit to a hostile page, or a single prompt-injected navigation, silently exposes whatever the user last copied. This is a cross-origin secret exposure on the default path: the page's own origin never changes, no navigation is needed, and the domain allowlist does not gate clipboard reads.
Reproduction (logic level, no browser needed)
Run against a checkout of the pinned commit (only bubus is imported beyond the standard library; every browser_use object is the real one):
import logging
from types import SimpleNamespace
logging.basicConfig(level=logging.CRITICAL)
from bubus import EventBus
from browser_use.browser import BrowserProfile
from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog
logger = logging.getLogger('repro')
logger.setLevel(logging.CRITICAL)
profile = BrowserProfile(allowed_domains=['https://good.test'])
wd = SecurityWatchdog.model_construct(
event_bus=EventBus(name='repro'),
browser_session=SimpleNamespace(browser_profile=profile, logger=logger),
)
print('Part 1: allowed_domains = ["https://good.test"]')
print(f' _is_url_allowed("https://good.test/page") = {wd._is_url_allowed("https://good.test/page")}')
print(f' _is_url_allowed("https://good.test.evil.com/pwned") = {wd._is_url_allowed("https://good.test.evil.com/pwned")}')
print('Part 2: default BrowserProfile (nothing configured)')
print(f' BrowserProfile().permissions = {BrowserProfile().permissions}')
Actual output (pinned commit)
Part 1: allowed_domains = ["https://good.test"]
_is_url_allowed("https://good.test/page") = True
_is_url_allowed("https://good.test.evil.com/pwned") = True
Part 2: default BrowserProfile (nothing configured)
BrowserProfile().permissions = ['clipboardReadWrite', 'notifications']
For part 2, invoking the real PermissionsWatchdog.on_BrowserConnectedEvent against a recording CDP stub records exactly one Browser.grantPermissions call with params {"permissions": ["clipboardReadWrite", "notifications"]} and no origin key, confirming the all-origins grant on the default connect path.
Expected vs actual
- Part 1 expected:
_is_url_allowed("https://good.test.evil.com/pwned")returns False, becausegood.test.evil.comis a different host. Actual: True (string prefix match). - Part 2 expected: a default profile grants no secret-bearing browser permission to every origin. Actual:
clipboardReadWriteis granted globally at connect, before any page is visited and regardless of any allowlist configuration.
Suggested fixes
- Part 1: parse the pattern with
urllib.parseand compare scheme and host structurally instead ofstr.startswithon the whole URL; when a pattern contains://, match the parsed netloc exactly and treat the remainder as a path prefix bound to a/boundary. This is a small, local change to_is_url_match. - Part 2: drop
clipboardReadWritefrom the defaultpermissionslist (keepnotifications, whose rationale is fingerprinting avoidance), document how to opt back in, and note in the field description thatBrowser.grantPermissionswithout anoriginapplies to every origin. A stricter follow-up would grant clipboard only per-origin for configuredallowed_domains, sinceBrowser.grantPermissionsaccepts anoriginparameter.
Source: browser-use/browser-use