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:
+18
-39
@@ -10,10 +10,8 @@ from browser_cli.client import (
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
key_for_remote,
|
||||
save_remote_token,
|
||||
send_command,
|
||||
remote_target_for_alias,
|
||||
token_for_remote,
|
||||
)
|
||||
from browser_cli.platform import endpoint_for_alias
|
||||
|
||||
@@ -94,29 +92,15 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
|
||||
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
|
||||
|
||||
|
||||
def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
endpoint = "browser-host.example:8765"
|
||||
|
||||
save_remote_token(endpoint, "secret-token")
|
||||
|
||||
assert token_for_remote(endpoint) == "secret-token"
|
||||
assert json.loads(remotes_path.read_text(encoding="utf-8")) == {
|
||||
endpoint: {"token": "secret-token"}
|
||||
}
|
||||
|
||||
|
||||
def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, token=None, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
|
||||
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -125,20 +109,19 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
|
||||
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
|
||||
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -151,13 +134,13 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == "host:8765"
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias("host:8765:work")
|
||||
@@ -172,7 +155,7 @@ def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkey
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
target = remote_target_for_alias(remote_host)
|
||||
@@ -186,13 +169,12 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
remote_host = "browser-host.example"
|
||||
remote_endpoint = f"{remote_host}:8765"
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
@@ -205,15 +187,15 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
assert send_command("tabs.list") == []
|
||||
assert sent["endpoint"] == remote_endpoint
|
||||
assert sent["_route"] == "work"
|
||||
assert sent["token"] == "secret"
|
||||
assert "token" not in sent
|
||||
|
||||
|
||||
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765", token="secret"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765", token="secret"),
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -224,27 +206,26 @@ def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, token=None, key=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
|
||||
lambda endpoint, key=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
|
||||
send_command("tabs.list", remote="host:8765", token="secret")
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
|
||||
|
||||
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
endpoint = "browser-host.example:8765"
|
||||
remotes_path.write_text(json.dumps({endpoint: {"token": "secret-token"}}), encoding="utf-8")
|
||||
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "browser-cli.targets"
|
||||
assert remote == endpoint
|
||||
assert token == "secret-token"
|
||||
return [{"profile": "work", "displayName": "work"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
@@ -255,7 +236,6 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].profile == "work"
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
assert targets[0].token == "secret-token"
|
||||
|
||||
|
||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
||||
@@ -268,7 +248,6 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
||||
|
||||
from pathlib import Path as _Path
|
||||
@@ -281,7 +260,7 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
lambda endpoint, token=None, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
|
||||
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
def fake_send_remote(endpoint, msg, private_key=None):
|
||||
|
||||
Reference in New Issue
Block a user