Is browser-use/browser-use safe?
browser-use is an AI python_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 10 risky constructs are reported for review. It can: filesystem read, filesystem write, mcp tools detected, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 80/100 (critical).
browser-use 0.13.3
- Sensitive-data access combined with network egress
- Python shell/command execution
- Remote pipe-to-shell execution
No malicious indicators found by static analysis.
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of browser-use/browser-use's authors. Report a false positive.
Findings (10)
The component both reads secret locations (keys, tokens) and can send data over the network.
PROJECT_API_KEY = 'phc_…[redacted, 47 chars]'
from urllib.parse import urlparse
cdp_url=urlparse(self.browser_session.cdp_url).hostname
from urllib.parse import urlparse
Why it matters: That combination is the classic path for quietly stealing credentials off your machine.
Fix: Verify that secrets read from disk are never transmitted off-host without explicit, auditable user consent.
An MCP tool exposes a powerful capability (files, shell, network, browser, or credentials).
"name": "retry_with_browser_use_agent",
"name": "browser_navigate",
"name": "browser_click",
"name": "browser_type",
"name": "browser_get_state",
"name": "browser_extract_content",
"name": "browser_scroll",
"name": "browser_go_back",
"name": "browser_list_tabs",
"name": "browser_switch_tab",
"name": "browser_close_tab",
Why it matters: Wired into an agent, these grant it real access to your machine — confirm each is required.
Fix: Confirm each powerful tool is required and constrained; broad MCP tools (shell/filesystem/network) grant an agent significant host access.
A hardcoded credential (API key, token, or private key) is shipped in the code.
PROJECT_API_KEY = 'phc_…[redacted, 47 chars]'
Why it matters: Anyone who gets the package gets the secret — rotate it and load secrets at runtime instead.
Fix: Remove the secret from the code, rotate it immediately, and load credentials from the environment or a secrets manager at runtime.
A remotely fetched payload is piped straight into a shell (e.g. `curl … | bash`). The component runs code downloaded at runtime, which is unreviewable and a common second-stage delivery vector.
curl -LsSf https://astral.sh/uv/install.sh | sh
Fix: Download to a file, inspect it, and run a pinned/verified copy instead of piping a network response directly into a shell.
The component can run operating-system commands or spawn processes.
result = subprocess.run( [str(binary), '--help'], capture_output=True, text=True, timeout=5, check=False, )
self.process = await asyncio.create_subprocess_exec( *self.command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=self.env, limit=self.stream_limit, )
result = subprocess.run(['which', cmd], capture_output=True, text=True)
subprocess = await asyncio.create_subprocess_exec( browser_path, *launch_args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, )
process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, )
result = subprocess.run(cmd)
result = subprocess.run([uv, 'tool', 'install', '--python', '3.12', '--upgrade', '--force', 'browser-use'])
result = subprocess.run([exe, 'skill'], capture_output=True, text=True)
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=package_root, stderr=subprocess.DEVNULL).decode().strip()
subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=package_root, stderr=subprocess.DEVNULL)
subprocess.check_output(['git', 'config', '--get', 'remote.origin.url'], cwd=package_root, stderr=subprocess.DEVNULL)
subprocess.check_output(['git', 'show', '-s', '--format=%ci', 'HEAD'], cwd=package_root, stderr=subprocess.DEVNULL)
subprocess.run(['open', file_path], check=True)
subprocess.run(['cmd', '/c', 'start', '', file_path], check=True)
subprocess.run(['xdg-open', file_path], check=True)
test_proc = await asyncio.create_subprocess_exec( path, '--version', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL )
process = await asyncio.create_subprocess_exec(*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; avoid shell=True.
A server is bound to all network interfaces (0.0.0.0), not just your own machine.
uvicorn.run('integrations.slack.slack_api:app', host='0.0.0.0', port=3000)Why it matters: Without authentication, other hosts on the network can reach it.
Fix: Bind to 127.0.0.1 for local-only use, or require authentication and restrict access if remote exposure is intended.
The component reads files from disk.
with open(path, 'rb') as f:
importlib.resources.files('browser_use.agent.system_prompts')
.joinpath(template_filename)
.open('r', encoding='utf-8') as fwith open(filepath, encoding='utf-8') as f:
loaded = json.loads(path.read_text())
data = base64.b64encode(image_path.read_bytes()).decode('ascii')with open(file_path, encoding='utf-8') as history_file:
with open(local_state_path, encoding='utf-8') as f:
with open(manifest_path, encoding='utf-8') as f:
with open(bg_path, encoding='utf-8') as f:
with open(crx_path, 'rb') as f:
with open(path_obj, 'rb') as f:
existing_state = json.loads(json_path.read_text())
content = await anyio.Path(str(load_path)).read_text()
if Path('/.dockerenv').exists() or 'docker' in Path('/proc/1/cgroup').read_text().lower():with open(config_path) as f:
with open(module.__file__) as f:
return skill_path.read_text(encoding='utf-8')
text = resources.files('browser_harness').joinpath('SKILL.md').read_text(encoding='utf-8')device_id = device_id_path.read_text().strip()
with open(config_path) as f:
with open(self.USER_ID_PATH) as f:
cached = CachedPricingData.model_validate_json(await anyio.Path(cache_file).read_text())
content = await anyio.Path(cache_file).read_text()
cached = CachedPricingData.model_validate_json(cache_file.read_text())
with open(pyproject_path, encoding='utf-8') as f:
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
await anyio.Path(target_path).write_text( await _format_conversation(input_messages, response), encoding=encoding or 'utf-8', )
with open(filepath, 'w', encoding='utf-8') as f:
target.write_text( json.dumps(self._conversation_snapshot(), indent=2, default=str), encoding=self.settings.save_conversation_path_encoding or 'utf-8', )
shutil.copytree( path_original_profile, path_temp_profile, ignore=_ignore_chrome_profile_transient_files, )
shutil.rmtree(temp_dir, ignore_errors=True)
shutil.copy(local_state_src, local_state_dst)
with open(bg_path, 'w', encoding='utf-8') as f:
with open(output_path, 'wb') as f:
shutil.rmtree(extract_dir)
os.unlink(temp_zip.name)
with open(filename, 'wb') as f:
output_file.write_text(json.dumps(storage_state, indent=2, ensure_ascii=False), encoding='utf-8')
Path(path).write_bytes(screenshot_data)
(sidecar_dir / filename).write_bytes(body_bytes)
(sidecar_dir / req_filename).write_bytes(post_data_bytes)
tmp_path.write_bytes(json.dumps(har_obj, indent=2, ensure_ascii=False).encode('utf-8'))shutil.rmtree(tmp_dir, ignore_errors=True)
shutil.rmtree(tmp_dir, ignore_errors=True)
shutil.rmtree(temp_path, ignore_errors=True)
temp_path.write_text(json.dumps(merged_state, indent=4, ensure_ascii=False), encoding='utf-8')
with open(config_path, 'w') as f:
with open(config_path, 'w') as f:
with open(config_path, 'w') as f:
file_path.write_text(self.content)
await asyncio.get_event_loop().run_in_executor(executor, lambda: file_path.write_text(self.content))
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component makes outbound network requests.
from urllib.parse import urlparse
cdp_url=urlparse(self.browser_session.cdp_url).hostname
from urllib.parse import urlparse
parsed = urlparse(url)
cdp_url=urlparse(cdp_url).hostname if cdp_url else None,
current_parts = urlparse(current)
requested_parts = urlparse(requested)
import httpx
self.client = httpx.AsyncClient(timeout=30.0)
from urllib.parse import urlparse
parsed_url = urlparse(url)
import urllib.request
with urllib.request.urlopen(url) as response:
from urllib.parse import urlparse, urlunparse
import httpx
host = urlparse(self.cdp_url).hostname or ''
parsed_url = urlparse(self.cdp_url)
url = urlunparse( (parsed_url.scheme, parsed_url.netloc, path, parsed_url.params, parsed_url.query, parsed_url.fragment) )
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=not is_localhost) as client:
from urllib.parse import urlparse
filename = urlparse(url).path.split('/')[-1]from urllib.parse import urlparse
return Path(urlparse(value).path if '://' in value else value).suffix.lower().lstrip('.') in _NETWORK_DOWNLOAD_FILE_EXTENSIONSparsed = urlparse(pdf_url)
Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
An MCP tool surface (manifest or tool definitions) was found.
"tools": [
Why it matters: Just context — review which tools it offers and their permissions.
Fix: Review the declared MCP tools and their permissions.
Check your own component
Run the same evidence-backed scan on any MCP server, agent skill, or package.
Scan your own componentOr get notified if this component's risk changes:
How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →