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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user