refactor: split compat into package, harden serve proxy (v0.9.3)
Testing / test (push) Failing after 10m21s
Testing / test (push) Failing after 10m21s
- compat.py → compat/ package: auth.py (auth-field normalizers), commands.py (command-format shims), __init__.py (re-exports) - Add _auth_0_9_3 transformer: normalizes pubkey to lowercase before auth so clients < 0.9.3 sending uppercase hex are accepted - adapt_auth() now called before auth check in serve.py; command extracted after adapt_auth so future transformers can rename commands safely - serve.py: deduplicate _recv_exact (import from client), unify resp/resp_payload across Windows/Unix branches, require lowercase hex pubkey (re.fullmatch), reorganize imports, drop unused os import - client.py: move payload/framed construction inside branches (remote path no longer serializes JSON it never uses); fix _is_valid_key_spec operator precedence; import MAX_MSG_BYTES from version_manager - auth.py: narrow except clause (ValueError instead of bare Exception) - Bump version 0.9.2 → 0.9.3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import (
|
||||
add_authorized_key,
|
||||
canonical_payload,
|
||||
generate_keypair,
|
||||
load_authorized_keys,
|
||||
load_authorized_keys_with_names,
|
||||
load_private_key,
|
||||
new_nonce,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from browser_cli.client import _is_valid_key_spec
|
||||
|
||||
|
||||
class TestGenerateKeypair:
|
||||
def test_returns_pem_and_hex(self):
|
||||
pem, pub_hex = generate_keypair()
|
||||
assert pem.startswith(b"-----BEGIN PRIVATE KEY-----")
|
||||
assert len(pub_hex) == 64
|
||||
|
||||
def test_each_call_unique(self):
|
||||
_, pub1 = generate_keypair()
|
||||
_, pub2 = generate_keypair()
|
||||
assert pub1 != pub2
|
||||
|
||||
|
||||
class TestCanonicalPayload:
|
||||
def test_strips_pubkey_and_sig(self):
|
||||
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def"}
|
||||
data = json.loads(canonical_payload(msg))
|
||||
assert "pubkey" not in data
|
||||
assert "sig" not in data
|
||||
|
||||
def test_keys_sorted(self):
|
||||
msg = {"z": 1, "a": 2, "m": 3}
|
||||
payload = canonical_payload(msg).decode()
|
||||
assert payload.index('"a"') < payload.index('"m"') < payload.index('"z"')
|
||||
|
||||
def test_deterministic(self):
|
||||
msg = {"b": 2, "a": 1}
|
||||
assert canonical_payload(msg) == canonical_payload(msg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def keypair(tmp_path):
|
||||
pem, pub_hex = generate_keypair()
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
return priv, pub_hex
|
||||
|
||||
|
||||
class TestSignVerify:
|
||||
def test_valid_signature_verifies(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "id": "uuid-1", "args": {}}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
assert verify(pub_hex, nonce, msg, sig) is True
|
||||
|
||||
def test_tampered_sig_fails(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list", "id": "x"}
|
||||
sign(priv, nonce, msg)
|
||||
assert verify(pub_hex, nonce, msg, "00" * 64) is False
|
||||
|
||||
def test_wrong_pubkey_fails(self, keypair):
|
||||
priv, _ = keypair
|
||||
_, other_pub = generate_keypair()
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list"}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
assert verify(other_pub, nonce, msg, sig) is False
|
||||
|
||||
def test_wrong_nonce_fails(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
nonce = bytes.fromhex(new_nonce())
|
||||
msg = {"command": "tabs.list"}
|
||||
sig = sign(priv, nonce, msg).hex()
|
||||
other_nonce = bytes.fromhex(new_nonce())
|
||||
assert verify(pub_hex, other_nonce, msg, sig) is False
|
||||
|
||||
def test_garbage_pub_hex_returns_false_not_exception(self):
|
||||
assert verify("not-hex!!!!", b"nonce", {}, "00" * 64) is False
|
||||
|
||||
def test_truncated_sig_hex_returns_false_not_exception(self, keypair):
|
||||
_, pub_hex = keypair
|
||||
assert verify(pub_hex, b"nonce", {}, "aabb") is False
|
||||
|
||||
def test_wrong_length_pubkey_returns_false_not_exception(self):
|
||||
assert verify("aabbcc", b"nonce", {}, "00" * 64) is False
|
||||
|
||||
|
||||
class TestAuthorizedKeys:
|
||||
def test_add_and_load(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
assert add_authorized_key(path, pub, "alice") is True
|
||||
assert pub in load_authorized_keys(path)
|
||||
|
||||
def test_add_duplicate_returns_false(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
add_authorized_key(path, pub)
|
||||
assert add_authorized_key(path, pub) is False
|
||||
|
||||
def test_load_with_names(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub1 = generate_keypair()
|
||||
_, pub2 = generate_keypair()
|
||||
add_authorized_key(path, pub1, "alice")
|
||||
add_authorized_key(path, pub2)
|
||||
entries = load_authorized_keys_with_names(path)
|
||||
assert (pub1, "alice") in entries
|
||||
assert (pub2, "") in entries
|
||||
|
||||
def test_ignores_comment_lines(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("# this is a comment\n")
|
||||
assert load_authorized_keys(path) == []
|
||||
|
||||
def test_returns_empty_for_missing_file(self, tmp_path):
|
||||
assert load_authorized_keys(tmp_path / "nofile") == []
|
||||
|
||||
|
||||
class TestIsValidKeySpec:
|
||||
def test_agent_bare(self):
|
||||
assert _is_valid_key_spec("agent") is True
|
||||
|
||||
def test_agent_with_selector(self):
|
||||
assert _is_valid_key_spec("agent:cardno:000012345678") is True
|
||||
|
||||
def test_absolute_pem_path(self):
|
||||
assert _is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
|
||||
|
||||
def test_dot_key_extension(self):
|
||||
assert _is_valid_key_spec("/tmp/mykey.key") is True
|
||||
|
||||
def test_angled_bracket_pem_rejected(self):
|
||||
# regression: operator precedence bug allowed "<garbage>.pem" to pass
|
||||
assert _is_valid_key_spec("<garbage>.pem") is False
|
||||
|
||||
def test_angled_bracket_key_rejected(self):
|
||||
assert _is_valid_key_spec("<garbage>.key") is False
|
||||
|
||||
def test_serialized_object_rejected(self):
|
||||
assert _is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
assert _is_valid_key_spec("") is False
|
||||
|
||||
def test_bare_filename_no_slash_no_ext_rejected(self):
|
||||
assert _is_valid_key_spec("mykey") is False
|
||||
+28
-28
@@ -262,12 +262,12 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -278,13 +278,13 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
|
||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.commands.tabs.remote_browser_targets",
|
||||
"browser_cli.commands.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
||||
), patch(
|
||||
"browser_cli.commands.tabs.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
@@ -297,13 +297,13 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
|
||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.tabs.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
||||
@@ -322,12 +322,12 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.tabs.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "count", "github"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -344,12 +344,12 @@ def test_group_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.groups.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["groups", "count"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -360,7 +360,7 @@ def test_group_count_multi_browser_shows_total():
|
||||
|
||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
with patch(
|
||||
"browser_cli.commands.groups.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
||||
):
|
||||
result = CliRunner().invoke(main, ["groups", "list"])
|
||||
@@ -372,7 +372,7 @@ def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
|
||||
|
||||
def test_tabs_move_accepts_right_short_alias():
|
||||
with patch("browser_cli.commands.tabs.send_command") as send_command:
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -384,7 +384,7 @@ def test_tabs_move_accepts_right_short_alias():
|
||||
|
||||
|
||||
def test_groups_move_accepts_left_short_alias():
|
||||
with patch("browser_cli.commands.groups.send_command") as send_command:
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -399,12 +399,12 @@ def test_windows_list_multi_browser_shows_browser_column():
|
||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.windows.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.windows.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["windows", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -420,12 +420,12 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.session.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["session", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -438,13 +438,13 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
|
||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.session.active_browser_targets",
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.session.send_command",
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
||||
@@ -455,7 +455,7 @@ def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
|
||||
|
||||
def test_windows_open_passes_url():
|
||||
with patch("browser_cli.commands.windows.send_command", return_value={"id": 7}) as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 7}) as send_command:
|
||||
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -463,20 +463,20 @@ def test_windows_open_passes_url():
|
||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||
|
||||
def test_extract_markdown_command():
|
||||
with patch("browser_cli.commands.extract.send_command", return_value="# Title") as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value="# Title") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "# Title\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None})
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
||||
|
||||
def test_extract_markdown_command_with_selector():
|
||||
with patch("browser_cli.commands.extract.send_command", return_value="## Post") as send_command:
|
||||
with patch("browser_cli.commands.send_command", return_value="## Post") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "## Post\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"})
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
||||
|
||||
|
||||
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
|
||||
@@ -561,7 +561,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
|
||||
"```"
|
||||
)
|
||||
with patch("browser_cli.commands.extract.send_command", return_value=raw):
|
||||
with patch("browser_cli.commands.send_command", return_value=raw):
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -639,8 +639,8 @@ def test_tabs_list_multi_browser_queries_remote_target():
|
||||
remote=endpoint,
|
||||
)
|
||||
|
||||
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.tabs.send_command",
|
||||
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Unit tests for the TCP serve layer (challenge-response auth, framing, rejection paths)."""
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, sign
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
|
||||
FAKE_UA = "browser-cli/0.9.3"
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
chunk = sock.recv(4 - len(raw))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw += chunk
|
||||
n = struct.unpack("<I", raw)[0]
|
||||
data = b""
|
||||
while len(data) < n:
|
||||
chunk = sock.recv(n - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _pair():
|
||||
return socket.socketpair()
|
||||
|
||||
|
||||
def _mock_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
|
||||
# ── challenge frame ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestChallenge:
|
||||
def test_challenge_sent_on_connect(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
challenge = _recv_framed(client)
|
||||
assert challenge["type"] == "challenge"
|
||||
assert "nonce" in challenge
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_challenge_includes_version_fields(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
challenge = _recv_framed(client)
|
||||
assert "server_version" in challenge
|
||||
assert "min_client_version" in challenge
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── rejection paths ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRejection:
|
||||
def _connect(self, auth_keys_path):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, auth_keys_path)
|
||||
challenge = _recv_framed(client)
|
||||
return client, t, challenge
|
||||
|
||||
def test_bad_user_agent_rejected(self):
|
||||
client, t, _ = self._connect(None)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "curl/7.88"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "forbidden" in resp["error"].lower() or "client" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_missing_pubkey_sig_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "unauthorized" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_untrusted_pubkey_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, trusted_pub = generate_keypair()
|
||||
path.write_text(trusted_pub + "\n")
|
||||
|
||||
pem, untrusted_pub = generate_keypair()
|
||||
key_path = tmp_path / "other.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, t, challenge = self._connect(path)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": untrusted_pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "untrusted" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_bad_signature_rejected(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
_, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
client, t, _ = self._connect(path)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub, "sig": "00" * 64}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "signature" in resp["error"].lower() or "invalid" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_oversized_message_rejected(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client) # consume challenge
|
||||
client.sendall(struct.pack("<I", 33 * 1024 * 1024))
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
assert "too large" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_invalid_json_rejected(self):
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client) # consume challenge
|
||||
bad = b"this is not json {"
|
||||
_send_framed(client, bad)
|
||||
resp = _recv_framed(client)
|
||||
assert resp["success"] is False
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── auth success paths ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuthSuccess:
|
||||
def test_valid_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""Correct signature → error must be 'browser not connected', not 'unauthorized'."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "unauthorized" not in resp["error"].lower()
|
||||
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_uppercase_pubkey_normalized_by_compat(self, tmp_path, monkeypatch):
|
||||
"""Clients < 0.9.3 may send uppercase pubkeys; compat layer normalises before auth."""
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair() # pub is lowercase hex
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
# old client sends uppercase pubkey
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.2", "pubkey": pub.upper()}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
|
||||
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, None),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
_recv_framed(client) # challenge
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
Reference in New Issue
Block a user