- add gemini-3-flash-preview-lite to VerifiedGeminiModels and token mappings
- list both gemini-3-flash-preview[-lite] alongside existing -latest aliases in CLOUD.md and api-v2.md SupportedLLMs
- swap example code and recommendations (examples/, AGENTS.md, quickstart.md, bug report placeholder) to gemini-3-flash-preview / -lite
- update Google CI test + evaluate_tasks judge LLM to gemini-3-flash-preview / -lite
Added header per integration
[guidelines](https://ai.google.dev/gemini-api/docs/partner-integration).
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Set the `x-goog-api-client` header for Google Chat requests to meet
Google partner integration guidelines. It identifies the client as
`browser-use/{version}` with a fallback of `unknown`.
- **New Features**
- Add `x-goog-api-client` header with value `browser-use/{version}`.
- Normalize and merge `http_options`; always set/override the header in
client params.
- **Bug Fixes**
- Handle both `types.HttpOptions` and `types.HttpOptionsDict`,
preserving `timeout` and existing headers.
- Add tests covering `None`, Pydantic, and dict `http_options` to ensure
the header is set and prefixed with `browser-use/`.
<sup>Written for commit 115199d2ba.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4884?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
## Summary
- Switch the `ChatBrowserUse` default `model` from `bu-latest` to
`bu-2-0`.
- Remap the `bu-latest` alias (both in `chat.py` normalization and the
`custom_pricing.py` pricing table) to `bu-2-0`, so anyone pinned to
`bu-latest` is upgraded along with the default.
- Also remap the `smart` pricing alias to `bu-2-0` so it tracks
`bu-latest`. Note: any historical usage records still emitting
`model="smart"` will be re-priced at `bu-2-0` rates (input $0.60/M,
output $3.50/M vs. bu-1-0's $0.20/M and $2.00/M).
- Refresh the docstring/usage example accordingly. `bu-1-0` is still
selectable as an explicit opt-in.
## Test plan
- [ ] `uv run pytest -vxs tests/ci/models/test_llm_browseruse.py`
(exercises the `bu-latest` alias path)
- [ ] `uv run pyright`
- [ ] Spot-check: `ChatBrowserUse()` (no args) resolves `.model ==
'bu-2-0'`
- [ ] Spot-check: `ChatBrowserUse(model='bu-latest').model == 'bu-2-0'`
## Summary
Closes **GHSA-p6cf-6hf7-xh7q** (critical) and **GHSA-hvch-rcrr-7g39**
(critical).
The agent's `evaluate()` action calls `Runtime.evaluate` directly
through CDP. `SecurityWatchdog` only subscribes to navigation events
(`NavigateToUrlEvent`, `NavigationCompleteEvent`, `TabCreatedEvent`), so
JS running inside an already-allowed page could:
- `fetch()` arbitrary internal URLs (SSRF inside the agent's network)
- Read `document.cookie` / `localStorage` / `sessionStorage` from any
allowed origin's context
- Read `window.location` of cross-frame contexts
- Otherwise act as if `allowed_domains` / `block_ip_addresses` weren't
configured
When a profile is configured with `allowed_domains` or
`block_ip_addresses`, the operator has signalled "this agent is
constrained". An unmediated JS evaluation primitive contradicts that
signal.
## Changes
- `browser_use/tools/service.py:evaluate` — refuse the action with an
explanatory `ActionResult(error=...)` when `profile.allowed_domains` is
truthy or `profile.block_ip_addresses` is True.
- Empty `allowed_domains=[]` is treated as "no restriction" elsewhere
(e.g. `SecurityWatchdog._is_url_allowed:195`). Behaves consistently —
does not refuse in that case.
## Test plan
- [x] `uv run pytest -vxs tests/ci/security/test_evaluate_restricted.py`
— 5 tests:
- refused when `allowed_domains=['example.com']`
- refused when `block_ip_addresses=True`
- refused when both
- proceeds to CDP on unrestricted profile (stub raises so the test
observes the guard did NOT short-circuit)
- proceeds when `allowed_domains=[]` (consistent with SecurityWatchdog
treating empty as no-restriction)
- [x] pyright / ruff check / ruff format — clean.
## Notes
This is the chosen approach from the design options I sketched (refuse
when restricted, vs route-through-event, vs lexical scan). Trade-off:
agents running on locked-down profiles lose `evaluate()` entirely.
Acceptable since restricted profiles are typically server-side /
production agents that don't need arbitrary JS.
If a future use case needs evaluate() inside restricted profiles, the
cleaner fix is to route through a watchdog-vetoable
`ExecuteJavaScriptEvent` — that's a larger refactor.
**Do not auto-merge** — please review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Refuses `evaluate()` on restricted browser profiles to prevent bypassing
domain/IP controls. Closes GHSA-p6cf-6hf7-xh7q and GHSA-hvch-rcrr-7g39
by blocking SSRF and cross-origin data access via `Runtime.evaluate`.
- **Bug Fixes**
- In `browser_use/tools/service.py`, `evaluate()` now returns
`ActionResult(error=...)` when `profile.allowed_domains`,
`profile.prohibited_domains`, or `profile.block_ip_addresses` is set;
`allowed_domains=[]` stays unrestricted.
- Added `tests/ci/security/test_evaluate_restricted.py` covering refusal
for `allowed_domains`, `prohibited_domains`, `block_ip_addresses`, and
both; and pass-through on unrestricted and empty-`allowed_domains`.
- **Refactors**
- Tightened the guard’s inline comment; no behavior change.
<sup>Written for commit f4d7ab4979.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4871?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
## Summary
- add `examples/integrations/README.md` with placement rules for
integration examples, package integrations, custom-function examples,
and external community projects
- add a link from `.github/CONTRIBUTING.md` so contributors can find the
guidance before opening integration PRs
- avoid adding any third-party example code directly; this only defines
the contribution boundary
Resolves#4744.
## Tests
- `uv run pre-commit run --all-files`
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds `examples/integrations/README.md` with placement rules, an example
checklist, and a community listing format, and links it from
`.github/CONTRIBUTING.md`. No third-party code added; this only sets the
contribution boundary. Resolves#4744.
<sup>Written for commit 7a33d8d251.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4856?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
Drop the running narrative; the function name + the one-line why is
enough. Error message stays actionable but no longer reiterates the
threat model.
No behavior change; 6/6 tests pass.
## Summary
The per-session HMAC auth token (commit `de14b9aa`, pre-0.12.6) gates
command dispatch on the daemon, but `asyncio.start_unix_server` creates
the socket file with the process umask — which is `0o022` by default,
leaving the socket at `0o755` (`srwxr-xr-x`). On multi-user hosts a
co-tenant can `connect()` and probe behavior even though the handshake
will ultimately fail.
This is the remaining sub-issue tracked by **GHSA-x6mv-rq4m-58g8** (the
RCE primitive itself was already closed by the auth-token fix).
## Changes
- `browser_use/skill_cli/daemon.py:399-415` — wrap `start_unix_server`
with `os.umask(0o077)` and explicitly `os.chmod(sock_path, 0o600)` after
bind. Matches the posture of the auth-token file at line 353.
## Test plan
- [x] `uv run pytest -vxs tests/ci/security/test_daemon_socket_perms.py`
— new test starts a real Daemon, waits for the bind, asserts os.stat
returns 0o600, signals shutdown. Pre-fix this failed with 0o755. Uses
/tmp for BROWSER_USE_HOME so the AF_UNIX path stays under the 104-byte
macOS cap.
- [x] pyright / ruff check / ruff format — clean.
## Notes
Part of the post-0.12.7 security cleanup. **Do not auto-merge** — please
review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Locks down the daemon’s Unix socket to owner-only access to block
co-tenant probing on multi-user hosts. Addresses the remaining sub-issue
from GHSA-x6mv-rq4m-58g8.
- **Bug Fixes**
- Enforce owner-only permissions on the Unix socket: wrap
`asyncio.start_unix_server` with `os.umask(0o077)` and explicitly
`chmod(sock_path, 0o600)` after bind; logs a warning if chmod fails.
Aligns with the auth-token file posture.
- Add `tests/ci/security/test_daemon_socket_perms.py` to assert the
socket mode is `0o600` after startup (skipped on Windows).
<sup>Written for commit 8c41cf795a.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4870?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
SecurityWatchdog enforces prohibited_domains as a navigation restriction
alongside allowed_domains. Without prohibited_domains in the evaluate()
guard, a profile using only the deny-list could still load an allowed page
and have the agent fetch() into a blocked domain via JS.
Add prohibited_domains to the restriction check.
The agent's evaluate() action called Runtime.evaluate directly through
CDP. SecurityWatchdog only subscribes to navigation events, so JS
running inside an already-allowed page could fetch() arbitrary internal
URLs, read cookies and localStorage from any allowed origin's context,
and otherwise act as if allowed_domains / block_ip_addresses weren't
configured.
When a profile has allowed_domains or block_ip_addresses set, the
operator has signalled the agent is constrained — exposing an unmediated
JS evaluation primitive contradicts that signal. Refuse evaluate()
outright on such profiles; agents that need JS can run on an unrestricted
profile.
Empty allowed_domains=[] is treated as 'no restriction' elsewhere in the
codebase (e.g. SecurityWatchdog); evaluate() behaves consistently and
does not refuse in that case.
The per-session HMAC auth token (de14b9aa) gates command dispatch, but
asyncio.start_unix_server creates the socket file with the process
umask, leaving it 0o755 by default. On multi-user hosts a co-tenant
can connect() and probe behavior even though the handshake will
ultimately fail.
Set umask to 0o077 around start_unix_server and chmod 0o600 after,
matching the auth-token file's posture.
<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Bumps `browser-use` version from 0.12.6 to 0.12.7 to publish a patch
release. Only updates pyproject.toml.
<sup>Written for commit c7085726d2.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4869?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
## Summary
Closes **GHSA-rv9j-wqjp-2fv4** (critical, `triage`) and the duplicate
medium reports **GHSA-66xh-g88g-2h8j** / **GHSA-hpr4-fqgr-xhj9**.
`DownloadsWatchdog` joined attacker-controlled filenames from CDP
(`Page.downloadWillBegin.suggestedFilename`) and `Content-Disposition`
headers directly into the configured `downloads_path`. Strings like
`../../escape.bin` or `/etc/shadow.bak` would `os.path.join` outside the
downloads directory, writing the fetched bytes (also attacker-controlled
— the response body IS the exploit content) to an arbitrary location
with the agent's process privileges.
`download_file_from_url` triggers passively for any
`Content-Disposition: attachment` response, so this is reachable from
**any visited site** — `allowed_domains` does **not** mitigate it.
## Changes
Two new private helpers on `DownloadsWatchdog`:
- `_sanitize_download_filename(name)` — keep only basename, normalize
Windows separators, strip null bytes, fall back to `'download'` for
empty / pure-traversal inputs.
- `_is_path_contained(path, dir)` — `os.path.realpath` containment check
for the on-disk sinks.
**Sanitizer wired at every attacker-controlled filename ingress:**
| Site | What it reads |
|------|---------------|
| `download_will_begin_handler` | CDP `suggestedFilename` (cache +
events) |
| `_handle_cdp_download` | same field, separate code path |
| Network-monitor `Content-Disposition` parser |
`re.search(...).group(1)` |
| `download_file_from_url(suggested_filename=...)` | upstream-passed
filename |
| `_handle_download` | Playwright `download.suggested_filename` |
**Containment check wired at every on-disk write site:**
- `download_file_from_url` write (line ~755)
- `_handle_download` (Playwright `save_as` path)
- `trigger_pdf_download` write (defense in depth — already basename'd,
but pinned)
## Test plan
- [x] `uv run pytest -vxs
tests/ci/security/test_download_filename_sanitization.py` — 16 new tests
covering: relative traversal, absolute Unix paths, Windows backslash
paths, mixed separators, pure-traversal fallback, null-byte stripping,
empty/None fallback, normal filename preservation, Unicode preservation;
containment helper (inside / nested / escape / dir-itself /
sibling-dir); `_get_unique_filename` collision handling on sanitized
input.
- [x] `uv run pytest -vx tests/ci/security/` — 80/80 pass (existing
security suite unchanged).
- [x] `uv run pyright` / `ruff check` / `ruff format` — clean.
- [ ] Full `uv run pytest -vxs tests/ci` on CI.
## Notes
This is the most reachable of the post-0.12.6 critical advisories — no
prompt injection or domain bypass needed, any visited site can trigger
it. Recommend prioritizing this PR's review.
**Do not auto-merge** — please review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a critical path traversal in download handling by sanitizing
attacker-controlled filenames and enforcing realpath containment before
writing files. Prevents arbitrary file writes via CDP
`suggestedFilename` and `Content-Disposition` from any visited site
(fixes GHSA-rv9j-wqjp-2fv4 and duplicates); trims comments/docstrings
with no behavior change.
- **Bug Fixes**
- Added `_sanitize_download_filename` and `_is_path_contained` helpers.
- Sanitized filenames from CDP events, Playwright
`download.suggested_filename`, `Content-Disposition`, and
`download_file_from_url`.
- Enforced containment at all write sites (`download_file_from_url`,
Playwright save path, PDF export); refuse writes outside `downloads_dir`
(covers symlink escapes).
- Added tests for traversal, absolute paths, Windows/mixed separators,
null bytes, empty/None, unicode, containment behavior, and
`_get_unique_filename` collision handling.
<sup>Written for commit f0413fbd8c.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4867?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
Drop the per-call-site rationale text; the helper names already convey
what's happening, and the WHY lives in the commit history. Shrink both
helper docstrings to one line.
No behavior change; 16/16 tests still pass.
## Summary
Closes **GHSA-xrfv-gg9f-wwjp** and **GHSA-g27c-8gp4-28cv** (both high,
`triage`, duplicate reports).
`SecurityWatchdog._is_ip_address` only recognized IP strings that
`ipaddress.ip_address()` accepts — i.e. the canonical dotted-quad form
and full IPv6. Chromium and the kernel resolver, however, also accept
several non-standard IPv4 representations:
| Form | Resolves to |
|------|-------------|
| `http://2130706433/` | `127.0.0.1` (decimal int) |
| `http://0x7f000001/` | `127.0.0.1` (hex) |
| `http://0177.0.0.1/` | `127.0.0.1` (octal) |
| `http://127.1/` | `127.0.0.1` (short-form) |
| `http://127.0.1/` | `127.0.0.1` (short-form) |
`block_ip_addresses=True` was therefore trivially bypassed by
re-encoding the IP in any of these forms.
## Changes
- `browser_use/browser/watchdogs/security_watchdog.py:_is_ip_address` —
after `ipaddress.ip_address()` fails, fall back to `socket.inet_aton`.
`inet_aton` accepts the same liberal IPv4 forms the kernel resolver
does, so the classifier matches the browser's behavior.
- IPv6 brackets stripped defensively before parsing.
## Test plan
- [x] `uv run pytest -vxs tests/ci/security/test_ip_blocking.py` — 34/34
pass.
- [x] New `TestNonStandardIPv4Representations` class covers: decimal,
hex, octal, short-form blocking; lookalike-domain non-interference
(`127.0.0.1.evil.com`, `2130706433.evil.com`); interaction with
`block_ip_addresses=False`; interaction with `allowed_domains=['*']`.
- [x] Existing `test_ipv4_lookalike_domains_allowed` was codifying the
buggy behavior for `1.2.3` (which IS a short-form IPv4 == 1.2.0.3 that
Chromium resolves). Removed that assertion and documented the
cross-reference.
- [x] `uv run pyright` / `ruff check` / `ruff format` — clean.
- [ ] Full `uv run pytest -vxs tests/ci` on CI.
## Notes
Part of the post-0.12.6 security advisory triage. **Do not auto-merge**
— please review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Closes GHSA-xrfv-gg9f-wwjp and GHSA-g27c-8gp4-28cv by canonicalizing
non-standard, percent-encoded, Unicode-variant, and IDNA-dot IPv4
hostnames so `block_ip_addresses=True` can’t be bypassed. Aligns
detection with browser/kernel behavior by stripping IPv6 brackets,
percent-decoding, NFKC-normalizing, folding IDNA label separators to
`.`, and falling back to `socket.inet_aton`; also simplifies the
`_is_ip_address` docstring with no behavior change.
- **Bug Fixes**
- In `_is_ip_address`, strip `[]`, percent-decode, NFKC-normalize, fold
`。`/`。`/`.` to `.`, try `ipaddress.ip_address()`, then fall back to
`socket.inet_aton` to catch decimal/hex/octal/short IPv4 (including
`%XX`-encoded and Unicode-digit forms); catch all exceptions; lookalike
domains (e.g., `127.0.0.1.evil.com`) remain allowed.
- **Tests**
- Added coverage for decimal/hex/octal/short IPv4 forms, percent-encoded
hosts, Unicode digit variants, IDNA dot separators (`。` `。` `.`), IDN
domains not misclassified, and malformed Unicode or `%` escapes; removed
the outdated `1.2.3` assertion.
<sup>Written for commit 4866656bba.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4866?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
Condense the running narrative across four prior fix commits into a single
docstring summarizing what canonicalization the function mirrors and the
never-throw invariant. Drop the per-step justifications — those live in
the commit messages where they belong.
No behavior change; 40/40 tests still pass.
Addresses third P1 codex review on #4866.
Per RFC 3490 / UTS46, four code points act as label separators in IDNA
processing — `.` (U+002E), `。` (U+3002 IDEOGRAPHIC FULL STOP),
`.` (U+FF0E FULLWIDTH FULL STOP), and `。` (U+FF61 HALFWIDTH IDEOGRAPHIC
FULL STOP). WHATWG URL parsing folds all four to `.` before resolution,
so `http://127。0。0。1/` and `http://127。0。0。1/` reach 127.0.0.1.
NFKC handles only U+FF0E and partially maps U+FF61 → U+3002, leaving the
IPv4 parser unable to match the dominant ideographic-dot form. Explicitly
replace U+3002 and U+FF61 with `.` after NFKC.
Test `test_idna_dot_separators_blocked` covers all four dot variants
both at `_is_url_allowed` and `_is_ip_address` levels, plus a combined
case with circled digits (`①②⑦。⓪。⓪。①`).
Addresses second P1 codex review on #4866.
WHATWG URL canonicalization maps fullwidth digits (`127.0.0.1`),
circled digits (`①②⑦.⓪.⓪.①`), and Unicode-prefixed hex forms
(`0x7f000001`) to ASCII IPv4 literals (`127.0.0.1` and `0x7f000001`)
before resolution. The classifier saw only the original string and
returned False, so `block_ip_addresses=True` was still bypassable by
re-encoding the IP with any equivalent Unicode digit variant.
Add a `unicodedata.normalize('NFKC', ...)` step after percent-decoding
and before parsing. NFKC handles all the bot's example forms and is
stdlib-only (no IDNA dependency). Wrapped in try/except to preserve the
never-throw invariant.
Tests:
- test_unicode_normalized_ipv4_blocked covers fullwidth, fullwidth+ASCII
hex, and circled-digit forms at both `_is_url_allowed` and
`_is_ip_address` boundaries.
- test_idn_domains_not_misclassified_as_ip is a false-positive guard
ensuring legitimate IDN domains (`café.example`, `日本.example`, their
punycode equivalents) remain classified as domains.
Addresses second codex review on #4866 (P1).
Chromium percent-decodes the host component before applying its IPv4
parser, so a URL like `http://%30x7f000001/` (decodes to `0x7f000001`,
i.e. 127.0.0.1) or `http://%31%32%37.0.0.1/` (decodes to `127.0.0.1`)
still reaches the IP address despite `block_ip_addresses=True`. Without
decoding, `_is_ip_address` sees the literal `%`-encoded string and
returns False — bypassing the block.
Call `urllib.parse.unquote` on the host before passing it to both
`ipaddress.ip_address` and `socket.inet_aton`. Wrap in try/except to
preserve the never-throw invariant for the classifier.
Adds two regression tests:
- test_percent_encoded_ipv4_blocked covers mixed (`%30x7f000001`), fully
encoded canonical (`%31%32%37.0.0.1`), and fully encoded decimal
(`%32%31%33%30%37%30%36%34%33%33`) bypass forms.
- test_malformed_percent_encoding_does_not_crash covers lone `%`, `%zz`,
`%2` — `unquote` leaves these as-is and the classifier must not throw.
Addresses codex review on #4866.
`socket.inet_aton` raises `UnicodeEncodeError` (not `OSError`) for
hostnames containing lone surrogates — common in URLs produced by
URL-decoding malformed UTF-8. The earlier patch only caught `OSError`,
so `_is_ip_address('\udcff')` would propagate the exception through
`_is_url_allowed` and crash the navigation security check whenever
`block_ip_addresses=True`.
Restore the original code's defensive `except Exception` posture for
both `ipaddress.ip_address` and `socket.inet_aton`. The classifier
should never throw — it returns True (recognized IP) or False (not a
recognizable IP); downstream domain-allowlist handling then applies.
Regression test covers lone-surrogate hostnames in
`test_malformed_unicode_hostnames_do_not_crash_classifier`.
## Summary
Closes **GHSA-j9hj-92j8-jv9h** (high, `triage`).
`upload_file` constructed the absolute upload path by joining
`file_system.get_dir()` with the **agent-controlled** `params.path`.
Because `FileSystem.get_file()` matches by basename (it does
`os.path.basename` first), an agent-controlled path like `../note.md`
would:
1. Pass `get_file()` lookup if a file named `note.md` exists in the
FileSystem.
2. Be naively joined to `data_dir`, producing `data_dir/../note.md` —
which resolves to a sibling file outside the FileSystem directory.
3. Be uploaded to the browser as the resolved (escaped) file, surfacing
arbitrary contents from outside `browseruse_agent_data` to whatever file
input was targeted.
## Changes
- `browser_use/tools/service.py:855` — use `file_obj.full_name` (the
FileSystem-owned basename) for the join instead of the raw
`params.path`.
- Added defense-in-depth realpath containment check that refuses uploads
whose resolved path falls outside `data_dir`.
## Test plan
- [x] `uv run pytest -vxs
tests/ci/security/test_upload_file_containment.py` — 2 new tests.
- Reproducer: `params.path='../note.md'` is forced through the action
with a stub session; the test captures the first `os.path.exists()`
argument inside `upload_file` and asserts via `realpath` that it stays
inside `data_dir`. Pre-fix it resolves to `tmp_path/note.md` (outside
`tmp_path/browseruse_agent_data`); post-fix it resolves to the safe
inside path.
- Sanity: with no basename match, the action returns the "not available"
rejection without ever entering the vulnerable join.
- [x] `uv run pyright browser_use/tools/service.py
tests/ci/security/test_upload_file_containment.py` — clean.
- [x] `uv run ruff check` / `format` — clean.
- [ ] Full `uv run pytest -vxs tests/ci` on CI.
## Notes
Part of the post-0.12.6 security advisory triage. **Do not auto-merge**
— please review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a path traversal in `upload_file` so uploads stay inside the agent
`FileSystem` directory and avoids rewriting remote paths to local files
(GHSA-j9hj-92j8-jv9h).
- **Bug Fixes**
- Build the upload path from `file_obj.full_name` instead of
agent-controlled `params.path`.
- Add an `os.path.realpath` containment check; refuse uploads that
resolve outside `file_system.get_dir()`.
- Only rewrite to `FileSystem` paths on local sessions; pass through
remote absolute paths even on basename collisions.
- Add three security tests covering traversal containment, the
no-basename-match case, and the remote-session collision case.
<sup>Written for commit 65a377c20f.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4865?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
Addresses codex review on #4865.
For remote (`is_local=False`) sessions, `params.path` is meant to reference
a file on the remote machine. A coincidental basename collision with a
local FileSystem-managed file (e.g. `/tmp/note.md` colliding with a local
managed `note.md`) would silently rewrite the upload to point at the local
file, uploading the wrong file with no indication to the agent.
Gate the FileSystem rewrite on `browser_session.is_local`. On remote
sessions, fall through to the existing pass-through branch that allows
remote-accessible absolute paths.
Add a regression test that exercises the remote-session basename-collision
case and asserts the local FileSystem path never appears in the resolved
upload path.
## Summary
Closes **GHSA-vfcm-843v-w6v3** (high, `triage`).
`retry_with_browser_use_agent` defaulted `allowed_domains` to `[]` when
the MCP client omitted the argument, then forwarded that value to
`BrowserProfile(allowed_domains=[])`. `SecurityWatchdog` treats an empty
list as "no allowlist configured — allow every URL", silently disabling
any admin-configured allowlist on the underlying profile.
## Changes
- `browser_use/mcp/server.py:493` — default changed from `[]` to `None`
so admin-configured defaults are preserved when the client omits the
argument.
- `browser_use/mcp/server.py:685` — override condition tightened (`if
allowed_domains:`) so an explicit empty list from the client is treated
as "no override" rather than "wipe the allowlist". Defense-in-depth for
clients that round-trip the old schema default.
- `browser_use/mcp/server.py:403-411` — removed the misleading
`'default': []` from the tool's input schema and clarified the docstring
so MCP clients see the new contract.
## Test plan
- [x] `uv run pytest -vxs tests/ci/security/test_mcp_allowed_domains.py`
— 3 new tests cover: (1) omitted arg → `None` forwarded, (2) explicit
list forwarded unchanged, (3) explicit `[]` does not wipe profile
defaults.
- [x] `uv run pyright browser_use/mcp/server.py
tests/ci/security/test_mcp_allowed_domains.py` — clean.
- [x] `uv run ruff check` / `format` — clean.
- [ ] Full `uv run pytest -vxs tests/ci` on CI.
## Notes
This is one of the items in the post-0.12.6 security advisory triage.
Other related fixes are in separate PRs so each can be reviewed and
merged independently. **Do not auto-merge** — please review.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a security bug where `retry_with_browser_use_agent` could disable
the domain allowlist by default. Omitting `allowed_domains` now
preserves server profile defaults; an empty list no longer wipes
restrictions (addresses GHSA-vfcm-843v-w6v3).
- **Bug Fixes**
- Default `allowed_domains` to `None` instead of `[]` when the client
omits it.
- Apply overrides only when `allowed_domains` is a non-empty list.
- Remove the schema’s `default: []` and clarify the input description to
reflect the new contract.
<sup>Written for commit 92defb6eae.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4864?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
GHSA-rv9j-wqjp-2fv4 (critical), GHSA-66xh-g88g-2h8j, GHSA-hpr4-fqgr-xhj9.
`DownloadsWatchdog` joined attacker-controlled filenames from CDP
(`Page.downloadWillBegin.suggestedFilename`) and `Content-Disposition`
headers directly into the configured `downloads_path`. Strings like
`../../escape.bin` or `/etc/shadow.bak` would `os.path.join` outside the
downloads directory, writing the fetched bytes (also attacker-controlled
— the response body is the exploit content) to an arbitrary location
with the agent's process privileges.
`download_file_from_url` triggers passively for any
`Content-Disposition: attachment` response, so this is reachable from any
visited site — `allowed_domains` does not mitigate it.
Add two private helpers on DownloadsWatchdog:
- `_sanitize_download_filename(name)`: keep only the basename, normalize
Windows separators, strip null bytes, fall back to `'download'` for
empty / pure-traversal inputs.
- `_is_path_contained(path, dir)`: realpath containment check for the
on-disk sinks.
Wire the sanitizer at every attacker-controlled filename ingress:
- `download_will_begin_handler` (CDP suggestedFilename → cache + events)
- `_handle_cdp_download` (same field, separate path)
- Network-monitor Content-Disposition parser
- `download_file_from_url` (suggested_filename argument)
- `_handle_download` (Playwright `download.suggested_filename`)
Wire the containment check at every on-disk write site:
- `download_file_from_url` write
- `_handle_download` (Playwright save_as path)
- `trigger_pdf_download` write (defense in depth — already basename'd)
GHSA-xrfv-gg9f-wwjp, GHSA-g27c-8gp4-28cv.
`SecurityWatchdog._is_ip_address` only recognized IP strings that
`ipaddress.ip_address()` accepts — i.e. the canonical dotted-quad form
(`127.0.0.1`) and full IPv6. Chromium and the kernel resolver, however,
also accept several non-standard IPv4 representations:
http://2130706433/ → 127.0.0.1 (decimal int)
http://0x7f000001/ → 127.0.0.1 (hex)
http://0177.0.0.1/ → 127.0.0.1 (octal)
http://127.1/ → 127.0.0.1 (short-form)
http://127.0.1/ → 127.0.0.1 (short-form)
`block_ip_addresses=True` was therefore trivially bypassed by re-encoding
the IP in any of these forms.
Fall back to `socket.inet_aton` after `ipaddress.ip_address()` fails — it
accepts the same liberal IPv4 forms the kernel resolver does, so the
classifier matches the browser's behavior.
The existing `test_ipv4_lookalike_domains_allowed` test was codifying the
buggy behavior for `1.2.3` (which IS a short-form IPv4 == 1.2.0.3).
Removed that assertion and added a dedicated `TestNonStandardIPv4Representations`
class covering decimal/hex/octal/short-form blocking, lookalike-domain
non-interference, and the interaction with `allowed_domains`.
GHSA-j9hj-92j8-jv9h.
The `upload_file` action constructed the absolute upload path by joining
`file_system.get_dir()` with the agent-controlled `params.path`. Because
`FileSystem.get_file()` matches by basename (`os.path.basename` first), an
agent-controlled path like `../note.md` would:
1. Pass `get_file()` lookup if a file named `note.md` exists in the FileSystem.
2. Be naively joined to `data_dir`, producing `data_dir/../note.md` — which
resolves to a sibling file outside the FileSystem directory.
3. Be uploaded to the browser as the resolved (escaped) file, surfacing
arbitrary contents from outside `browseruse_agent_data` to whatever file
input was targeted.
Use the FileSystem-owned `file_obj.full_name` for the join. Add a
`os.path.realpath` containment check as defense in depth; if it ever resolves
outside `data_dir`, refuse the upload.
GHSA-vfcm-843v-w6v3.
`retry_with_browser_use_agent` defaulted `allowed_domains` to `[]` when the
client omitted the argument, and then forwarded that value to
`BrowserProfile(allowed_domains=[])`. `SecurityWatchdog` interprets the empty
list as "no allowlist configured — allow every URL", silently disabling any
admin-configured allowlist on the underlying profile.
Default to `None` so admin profile defaults are preserved when the client
omits the argument, and treat an explicit empty list the same as omitting
(falsy override is not applied). Schema default removed and description
updated so MCP clients see the new contract.
<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Update `python-dotenv` from 1.2.1 to 1.2.2 to pull in upstream patch
fixes and keep env loading reliable. No code changes; only the
dependency in `pyproject.toml` was bumped.
<sup>Written for commit ab00e1a6ac.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4863?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
## Summary
- Fixes ENG-4710: passing `proxy_country_code=None` to `BrowserSession`
now correctly disables the proxy instead of silently falling back to the
server default (US proxy).
- Adds an `_UNSET` sentinel to distinguish "not passed" (let server
apply default) from "explicitly None" (disable proxy).
- When `proxy_country_code=None` is passed, it is included in the
`CreateBrowserRequest` so `model_dump(exclude_unset=True)` sends
`"proxyCountryCode": null` to the API, which the server correctly
interprets as "no proxy."
## Test plan
- [ ] Verify `BrowserSession(use_cloud=True, proxy_country_code=None)`
sends `proxyCountryCode: null` in the API request body
- [ ] Verify `BrowserSession(use_cloud=True, proxy_country_code="de")`
still sends `proxyCountryCode: "de"`
- [ ] Verify `BrowserSession(use_cloud=True)` omits `proxyCountryCode`
(server defaults to US)
- [ ] Verify `BrowserSession(use_cloud=True,
cloud_proxy_country_code=None)` also disables proxy correctly
- [ ] Run `uv run pytest -vxs tests/ci` to confirm no regressions
Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes ENG-4710: passing proxy_country_code=None to BrowserSession now
disables the proxy in cloud mode instead of defaulting to US. Introduces
an _UNSET sentinel so “not provided” omits the field while explicit None
sends null to the API.
- **Bug Fixes**
- Added _UNSET to distinguish unset vs None for proxy parameters.
- Precedence: prefer cloud_proxy_country_code, else proxy_country_code;
honor explicit None.
- CreateBrowserRequest only includes provided fields; sends
proxyCountryCode: null when None, omits when unset.
<sup>Written for commit 9a6510a85c.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browser-use/browser-use/pull/4861?utm_source=github">Review
in cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
When a user passes proxy_country_code=None to BrowserSession, they
intend to disable the proxy. Previously, None was indistinguishable
from "not passed" because both used None as the default value. The
server then applied its default (US proxy) instead of disabling it.
Introduces an _UNSET sentinel to distinguish "not passed" (server
default) from "explicitly None" (no proxy). When proxy_country_code
is explicitly set to None, it is now included in the CreateBrowserRequest
and sent as null in the API body, which the server correctly interprets
as "disable proxy".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Problem
`Failed to input text via CDP: TypeError: ord() expected a character,
but string of length 2 found` — a burst of ~30 occurrences appeared in
production within the last hour (across 5 distinct sessions), all in the
agent-worker Lambda.
## Root cause
`browser_use/browser/watchdogs/default_action_watchdog.py` and
`browser_use/actor/element.py` call \`ord(char.upper())\` /
\`ord(char.lower())\` to derive a Windows virtual-key code for each
typed character.
A handful of Unicode characters expand on case conversion, so
\`char.upper()\` returns more than one code point and \`ord()\` raises:
| char | .upper() | len |
|---|---|---|
| \`ß\` | \`SS\` | 2 |
| \`ffi\` | \`FFI\` | 3 |
| \`ʼn\` | \`ʼN\` | 2 |
| \`ff\` | \`FF\` | 2 |
| \`fi\` | \`FI\` | 2 |
Any task that types German \"ß\" or one of the Latin ligatures aborts
the action.
## Fix
Wrap the conversion in a helper that falls back to the original code
point when \`upper()\` expands:
\`\`\`python
def _vk_from(c: str) -> int:
up = c.upper()
return ord(up) if len(up) == 1 else ord(c)
\`\`\`
The Windows VK code is only approximate (CDP uses the \`text\` field for
the actual character anyway), so this preserves the existing behavior
for ASCII while keeping typing alive for the rest of Unicode. The
lowercase fallback (\`char.lower()[:1] or char\`) prevents the same edge
case in the uppercase branch's returned \`base_key\`.
## Test plan
- [ ] Existing typing tests still pass
- [ ] Typing \"Straße\", \"ffi\", etc. into a text input no longer raises
TypeError
- [ ] Production: error pattern \`TypeError: ord() expected a
character\` should drop to 0 after deploy
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a CDP typing crash caused by Unicode characters whose upper/lower
case expands (e.g., ß → SS, ffi → FFI), which made ord() raise and aborted
actions. We now derive a safe VK code that falls back to the original
character so typing continues normally.
- Bug Fixes
- Added `\_vk_from` to use `ord(upper)` only when it’s a single code
point; otherwise fall back to `ord(char)`.
- Applied in both typing paths and adjusted uppercase handling
(`char.lower()[:1]`) to avoid multi-character expansions.
<sup>Written for commit 02dd650a19.
Summary will update on new commits.</sup>
<!-- End of auto-generated description by cubic. -->
ord() raises TypeError when called on a multi-character string. Several
Unicode characters expand on case conversion ('ß'.upper() == 'SS',
'ffi'.upper() == 'FFI', 'ʼn'.upper() == 'ʼN'). Typing any of these caused
'Failed to input text via CDP: TypeError: ord() expected a character,
but string of length 2 found' and aborted the action.
Fall back to the original character's code point when upper()/lower()
returns more than one code point. The Windows VK code is only an
approximation anyway; the 'text' field passed to dispatchKeyEvent
carries the actual character.
## Summary
- skip transient Chrome profile lock/journal files when cloning a Chrome
profile into the browser-use temp user-data directory
- turn Windows/profile-copy sharing violations into an actionable error
that tells users to close Chrome or connect to the running browser with
`--cdp-url`
- add focused regression coverage for the ignored transient files and
the locked-profile error path
Fixes#4546.
## Tests
- `uv run ruff check browser_use/browser/profile.py
tests/ci/browser/test_profile_copy.py`
- `uv run ruff format --check browser_use/browser/profile.py
tests/ci/browser/test_profile_copy.py`
- `uv run pytest tests/ci/browser/test_profile_copy.py -q`
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Handles locked Chrome profile copies on Windows by skipping transient
lock/journal files and surfacing a clear, actionable error. Prevents
failed launches and keeps temp profiles clean.
- **Bug Fixes**
- Ignore transient files during profile copy: `Singleton*`, `*.lock`,
`*-journal`, `LOCK`, `LOCKFILE`.
- Detect sharing violations (`PermissionError`/WinError 32), clean up
temp dir, and raise an actionable error prompting users to close Chrome
or connect with `--cdp-url`.
- Regression tests for skipped files and locked-profile error path;
tests now pass `pyright`.
<sup>Written for commit ae52ce8dcd.
Summary will update on new commits.</sup>
<!-- End of auto-generated description by cubic. -->