feat: token-auth removal, security hardening, Stripe-style compat layer (v0.9.2)
Testing / test (push) Successful in 41s
Package Extension / package-extension (push) Successful in 35s
Build & Publish Package / publish (push) Successful in 46s

- Remove token auth entirely; only Ed25519 pubkey auth or --no-auth
- Add 32 MB message-size cap in serve and client (DoS protection)
- Set Unix socket to 0o600 after bind in native_host (multi-user hardening)
- Enforce browser-cli/VERSION user-agent on all TCP connections
- Add PROTOCOL_MIN_CLIENT check (>= 0.9.0) server- and client-side
- Include server_version + min_client_version in challenge frame
- Add browser_cli/version_manager.py: parse_version, get_installed_version
- Add browser_cli/compat.py: Stripe-style versioning layer with adapt_request
  / adapt_response hooks; baseline 0.9.2, no shims needed yet
- Fix BrowserCLI key handling: no Path() wrap for agent specs
- Fix _multi_browser_targets() to forward key to remote_browser_targets()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 21:59:46 +02:00
parent b98c4ae116
commit c1a5ef9dd7
17 changed files with 267 additions and 237 deletions
+58 -38
View File
@@ -1,7 +1,12 @@
import hmac, threading, secrets, socket, struct, click, json, sys
import re, threading, secrets, socket, struct, click, json, sys, os
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version, get_installed_version
from browser_cli.compat import adapt_request, adapt_response
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
_MAX_MSG_BYTES = 32 * 1024 * 1024
from rich.console import Console
from datetime import datetime
@@ -25,17 +30,10 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
@@ -43,6 +41,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > _MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
@@ -53,6 +61,22 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
msg_id = msg.get("id")
command = msg.get("command", "?")
# ── user-agent + version check ────────────────────────────────────────────
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, command, None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
@@ -70,11 +94,6 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
elif server_token is not None:
if not hmac.compare_digest(msg.get("token") or "", server_token):
_send_error(msg_id, "unauthorized: invalid or missing token")
_log(addr, command, None, "DENIED", "bad token")
return
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
@@ -120,13 +139,12 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
resolved_profile = msg.get("_route") or profile
strip = {"token", "_route", "pubkey", "sig"}
if strip & msg.keys():
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
clean_header = struct.pack("<I", len(clean_payload))
else:
clean_payload = payload
clean_header = header
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
clean_header = struct.pack("<I", len(clean_payload))
try:
sock_path = _resolve_socket(resolved_profile)
@@ -141,6 +159,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp = pipe.recv_bytes()
resp = adapt_response(resp, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
@@ -149,7 +168,8 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
resp_header = _recv_exact(local, 4)
resp_len = struct.unpack("<I", resp_header)[0]
resp_payload = _recv_exact(local, resp_len)
client_sock.sendall(resp_header + resp_payload)
resp_payload = adapt_response(resp_payload, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
resp_data = json.loads(resp_payload if not is_windows() else resp)
if resp_data.get("success", True):
@@ -160,7 +180,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys_path:"Path|None") -> None:
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
@@ -174,24 +194,29 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, serv
else:
auth_keys = None
nonce = secrets.token_hex(32)
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
challenge = json.dumps({
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}).encode()
try:
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, server_token, auth_keys, auth_keys_path, nonce)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
finally:
_CONN_LIMIT.release()
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Enables pubkey auth.")
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.pass_context
def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
@@ -203,13 +228,11 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
server_token = None
elif no_auth:
auth_keys_path = None
server_token = None
else:
auth_keys_path = None
server_token = token or secrets.token_urlsafe(32)
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -221,8 +244,9 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
sys.exit(1)
server.listen(16)
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
@@ -230,10 +254,6 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
elif server_token:
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
@@ -244,7 +264,7 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys_path), daemon=True).start()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally: