feat: token-auth removal, security hardening, Stripe-style compat layer (v0.9.2)
- 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:
+33
-36
@@ -21,6 +21,12 @@ from typing import Any
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
|
||||
except Exception:
|
||||
_USER_AGENT = "browser-cli/0"
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
|
||||
@@ -36,7 +42,6 @@ class BrowserTarget:
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
def _active_endpoints(reg: dict) -> dict:
|
||||
@@ -64,27 +69,6 @@ def _load_remotes() -> dict[str, dict[str, str]]:
|
||||
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
|
||||
|
||||
|
||||
def save_remote_token(endpoint: str, token: str | None) -> None:
|
||||
"""Persist the auth token for a remote endpoint used by this client."""
|
||||
if not endpoint or not token:
|
||||
return
|
||||
remotes = _load_remotes()
|
||||
current = remotes.get(endpoint, {})
|
||||
current["token"] = token
|
||||
remotes[endpoint] = current
|
||||
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(remotes, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def token_for_remote(endpoint: str | None) -> str | None:
|
||||
if not endpoint:
|
||||
return None
|
||||
cfg = _load_remotes().get(endpoint) or {}
|
||||
token = cfg.get("token")
|
||||
return str(token) if token else None
|
||||
|
||||
|
||||
def _is_valid_key_spec(s: str) -> bool:
|
||||
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
|
||||
@@ -127,9 +111,9 @@ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) ->
|
||||
return f"{remote_name}:{display_name or profile_name}"
|
||||
|
||||
|
||||
def remote_browser_targets(endpoint: str, token: str | None = None, key=None) -> list[BrowserTarget]:
|
||||
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
"""Return browser targets advertised by a single remote endpoint."""
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token, key=key)
|
||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in remote_targets or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
@@ -140,7 +124,6 @@ def remote_browser_targets(endpoint: str, token: str | None = None, key=None) ->
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -148,10 +131,9 @@ def remote_browser_targets(endpoint: str, token: str | None = None, key=None) ->
|
||||
|
||||
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint, cfg in _load_remotes().items():
|
||||
token = str(cfg.get("token") or "") or None
|
||||
for endpoint in _load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, token, key=key))
|
||||
targets.extend(remote_browser_targets(endpoint, key=key))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
return targets
|
||||
@@ -273,6 +255,19 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
nonce_hex = None
|
||||
|
||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||
if min_ver:
|
||||
from browser_cli.version_manager import parse_version
|
||||
try:
|
||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
||||
if parse_version(client_ver) < parse_version(min_ver):
|
||||
raise BrowserNotConnected(
|
||||
f"Client version {client_ver} is too old for this server "
|
||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
if nonce_hex and private_key is not None:
|
||||
from browser_cli.auth import sign, public_key_hex
|
||||
nonce = bytes.fromhex(nonce_hex)
|
||||
@@ -286,8 +281,8 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
|
||||
return _recv_all(sock)
|
||||
|
||||
|
||||
def _auto_route_remote(endpoint: str, token: str | None, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, token, key=key)
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
return targets[0].profile
|
||||
if len(targets) > 1:
|
||||
@@ -300,7 +295,7 @@ def _auto_route_remote(endpoint: str, token: str | None, key=None) -> str | None
|
||||
return None
|
||||
|
||||
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None, key: "Path | None" = None) -> Any:
|
||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
@@ -311,26 +306,23 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
requested_profile = remote_alias_target.profile
|
||||
|
||||
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
|
||||
msg = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"command": command,
|
||||
"args": args or {},
|
||||
}
|
||||
if remote_endpoint:
|
||||
msg["user_agent"] = _USER_AGENT
|
||||
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
|
||||
key_spec = key if key is not None else key_for_remote(remote_endpoint)
|
||||
private_key = _load_private_key(key_spec)
|
||||
# persist explicit key spec so future calls don't need --key
|
||||
if key is not None:
|
||||
save_remote_key(remote_endpoint, str(key))
|
||||
# use token auth only when no Ed25519 key is available
|
||||
if private_key is None and resolved_token:
|
||||
msg["token"] = resolved_token
|
||||
route_profile = requested_profile
|
||||
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
||||
if not route_profile and command not in _no_route_commands:
|
||||
route_profile = _auto_route_remote(remote_endpoint, resolved_token, key=key_spec)
|
||||
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
else:
|
||||
@@ -376,9 +368,14 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
return result.get("data")
|
||||
|
||||
|
||||
_MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
|
||||
|
||||
def _recv_all(sock: socket.socket) -> bytes:
|
||||
raw_len = _recv_exact(sock, 4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
if msg_len > _MAX_MSG_BYTES:
|
||||
raise ConnectionError(f"Response too large ({msg_len} bytes)")
|
||||
return _recv_exact(sock, msg_len)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user