fix: add per-session auth token to daemon socket (#4598)

## Summary

- **Root cause**: The daemon accepted any connection on its socket
without authentication. On Windows the port is deterministic
(`adler32(session)`-derived), so any local process could connect and
dispatch the `python` action, executing arbitrary code via
`eval()`/`exec()` as the daemon owner. On Unix the socket may be
world-writable under a permissive umask.
- **Fix**: On `Daemon.run()`, generate a `secrets.token_hex(32)` token,
write it atomically to `~/.browser-use/{session}.token` with `chmod
0o600`. Validate every incoming request with `hmac.compare_digest`
before dispatching. Delete the token file on shutdown.
- **Client**: `send_command()` in `main.py` now reads the token file and
attaches it to every request. Falls back to `''` for old daemons
(no-op).

## Test plan

- [ ] Start daemon, run `browser-use python "1+1"` — should work
normally
- [ ] Send a raw request without token to socket — should get
`{"success": false, "error": "Unauthorized"}`
- [ ] Unauthenticated `shutdown` action should be ignored (daemon stays
up)
- [ ] After daemon stops, `~/.browser-use/default.token` should be
deleted
- [ ] All CI tests pass (`uv run pytest -vxs tests/ci`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Secure the daemon socket with a per-session auth token to block
unauthorized local connections and arbitrary code execution via the
`python` action. The CLI now reads the token and includes it with each
command.

- **Bug Fixes**
- Generate a session token in `Daemon.run()` (`secrets.token_hex(32)`);
write it atomically to `~/.browser-use/{session}.token` via a `*.tmp`
file created with `0o600`, then replace; raise if write fails; delete on
shutdown.
- Validate every request using `hmac.compare_digest`; unauthorized calls
return `{"success": false, "error": "Unauthorized"}` and `shutdown` is
honored only for authorized, successful requests.
- Client `send_command()` reads the token and sends it as `token`; falls
back to `''` for older daemons.

<sup>Written for commit ca2185ba61.
Summary will update on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Saurav Panda
2026-04-03 10:54:19 -07:00
committed by GitHub
3 changed files with 62 additions and 6 deletions
+43 -6
View File
@@ -64,6 +64,7 @@ class Daemon:
self._idle_timeout: float = 30 * 60.0 # 30 minutes
self._idle_watchdog_task: asyncio.Task | None = None
self._is_shutting_down: bool = False
self._auth_token: str = ''
def _write_state(self, phase: str) -> None:
"""Atomically write session state file for CLI observability."""
@@ -220,8 +221,19 @@ class Daemon:
request = {}
try:
import hmac
request = json.loads(line.decode())
response = await self.dispatch(request)
req_id = request.get('id', '')
# Reject requests that don't carry the correct auth token.
# Use hmac.compare_digest to prevent timing-oracle attacks.
if self._auth_token and not hmac.compare_digest(
request.get('token', ''),
self._auth_token,
):
response = {'id': req_id, 'success': False, 'error': 'Unauthorized'}
else:
response = await self.dispatch(request)
except json.JSONDecodeError as e:
response = {'id': '', 'success': False, 'error': f'Invalid JSON: {e}'}
except Exception as e:
@@ -231,7 +243,7 @@ class Daemon:
writer.write((json.dumps(response) + '\n').encode())
await writer.drain()
if request.get('action') == 'shutdown':
if response.get('success') and request.get('action') == 'shutdown':
self._request_shutdown()
except TimeoutError:
@@ -322,10 +334,34 @@ class Daemon:
Stale sockets are cleaned up by is_daemon_alive() and by the next
daemon's startup (unlink before bind).
"""
from browser_use.skill_cli.utils import get_pid_path, get_socket_path
import secrets
from browser_use.skill_cli.utils import get_auth_token_path, get_pid_path, get_socket_path
self._write_state('initializing')
# Generate and persist a per-session auth token.
# The client reads this file to authenticate its requests, preventing
# any other local process from sending commands to the daemon socket.
# Create the temp file with 0o600 at open() time to avoid a permission
# race window where the file exists but is not yet restricted.
# Raise on failure — running without a readable token file leaves the
# daemon permanently unauthorized for all clients.
self._auth_token = secrets.token_hex(32)
token_path = get_auth_token_path(self.session)
tmp_token = token_path.with_suffix('.token.tmp')
fd = os.open(str(tmp_token), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, 'w') as f:
f.write(self._auth_token)
except OSError:
try:
tmp_token.unlink(missing_ok=True)
except OSError:
pass
raise
os.replace(tmp_token, token_path)
# Setup signal handlers
loop = asyncio.get_running_loop()
@@ -426,11 +462,10 @@ class Daemon:
logger.warning(f'Error closing session: {e}')
self._session = None
# Delete PID file last, right before exit. If browser cleanup hangs above,
# the PID file still exists so `sessions` can discover the orphaned daemon.
# Delete PID and auth token files last, right before exit.
import os
from browser_use.skill_cli.utils import get_pid_path
from browser_use.skill_cli.utils import get_auth_token_path, get_pid_path
pid_path = get_pid_path(self.session)
try:
@@ -439,6 +474,8 @@ class Daemon:
except (OSError, ValueError):
pass
get_auth_token_path(self.session).unlink(missing_ok=True)
self._write_state('stopped')
# Force exit — the asyncio server's __aexit__ hangs waiting for the
+14
View File
@@ -181,6 +181,19 @@ def _get_pid_path(session: str = 'default') -> Path:
return _get_home_dir() / f'{session}.pid'
def _read_auth_token(session: str = 'default') -> str:
"""Read per-session auth token written by the daemon.
Must match utils.get_auth_token_path().
Returns empty string if the token file is missing (pre-auth daemon).
"""
token_path = _get_home_dir() / f'{session}.token'
try:
return token_path.read_text().strip()
except OSError:
return ''
def _connect_to_daemon(timeout: float = 60.0, session: str = 'default') -> socket.socket:
"""Connect to daemon socket."""
sock_path = _get_socket_path(session)
@@ -563,6 +576,7 @@ def send_command(action: str, params: dict, *, session: str = 'default', agent_i
'action': action,
'params': params,
'agent_id': agent_id,
'token': _read_auth_token(session),
}
sock = _connect_to_daemon(session=session)
+5
View File
@@ -75,6 +75,11 @@ def get_pid_path(session: str = 'default') -> Path:
return get_home_dir() / f'{session}.pid'
def get_auth_token_path(session: str = 'default') -> Path:
"""Get auth token file path for a session."""
return get_home_dir() / f'{session}.token'
def find_chrome_executable() -> str | None:
"""Find Chrome/Chromium executable on the system."""
system = platform.system()