feat!: harden raw browser control and packaging
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
- Add safe-by-default policy gates for raw command surfaces: command, script, and serve-http /command. - Require explicit opt-ins for page reads, browser control, and high-risk commands such as dom.eval, storage.*, and screenshots. - Remove all cookies support from CLI, SDK, extension commands, permissions, constants, docs, and tests. - Add diagnostic, events, watch, workspace, remote, raw command, script, HTTP gateway, tree-view, session import/export, and extension info/capability commands. - Add Chrome Web Store packaging that strips manifest.key while keeping local packages with a stable native-messaging extension ID. - Bump browser-cli and extension version to 0.14.1 and cover the new behavior with pytest and extension packaging tests. BREAKING CHANGE: cookies commands and the b.cookies SDK namespace have been removed; generic raw command execution now blocks non-safe commands unless explicitly allowed.
This commit is contained in:
+1
-8
@@ -76,7 +76,7 @@ class TestBrowserCLIInit:
|
||||
def test_namespaces_present_and_bound(self):
|
||||
b = BrowserCLI()
|
||||
for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
|
||||
"page", "storage", "cookies", "session", "perf", "extension", "decorators"):
|
||||
"page", "storage", "session", "perf", "extension", "decorators"):
|
||||
ns = getattr(b, name)
|
||||
assert ns is not None
|
||||
assert ns._c is b
|
||||
@@ -792,13 +792,6 @@ class TestPageStorageCookies:
|
||||
"storage.set", {"key": "k", "value": "v", "type": "session", "tabId": None}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_cookies_list(self, b, mock_send):
|
||||
mock_send.return_value = [{"name": "c"}]
|
||||
assert b.cookies.list(domain="example.com") == [{"name": "c"}]
|
||||
mock_send.assert_called_once_with(
|
||||
"cookies.list", {"url": None, "domain": "example.com", "name": None}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
class TestPerf:
|
||||
def test_perf_status(self, b, mock_send):
|
||||
mock_send.return_value = {"profile": "auto"}
|
||||
|
||||
@@ -168,70 +168,6 @@ def test_cli_dom_poll():
|
||||
assert result.exit_code == 0
|
||||
assert "Matched" in result.output
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cookies commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from browser_cli.commands.cookies import cookies_group
|
||||
|
||||
def test_cli_cookies_list_empty():
|
||||
result = _run(cookies_group, ["list"], [])
|
||||
assert result.exit_code == 0
|
||||
assert "No cookies found" in result.output
|
||||
|
||||
def test_cli_cookies_list_with_cookies():
|
||||
cookies = [{"name": "session", "value": "abc123", "domain": "example.com",
|
||||
"path": "/", "secure": True, "httpOnly": False}]
|
||||
result = _run(cookies_group, ["list"], cookies)
|
||||
assert result.exit_code == 0
|
||||
assert "session" in result.output
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_cookies_list_filter_url():
|
||||
cookies = [{"name": "x", "value": "y", "domain": "example.com",
|
||||
"path": "/", "secure": False, "httpOnly": False}]
|
||||
result = _run(cookies_group, ["list", "--url", "https://example.com"], cookies)
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_cookies_list_filter_domain():
|
||||
cookies = [{"name": "x", "value": "y", "domain": "example.com",
|
||||
"path": "/", "secure": False, "httpOnly": False}]
|
||||
result = _run(cookies_group, ["list", "--domain", "example.com"], cookies)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_cli_cookies_list_filter_name():
|
||||
cookies = [{"name": "session", "value": "abc", "domain": "example.com",
|
||||
"path": "/", "secure": False, "httpOnly": True}]
|
||||
result = _run(cookies_group, ["list", "--name", "session"], cookies)
|
||||
assert result.exit_code == 0
|
||||
assert "session" in result.output
|
||||
|
||||
def test_cli_cookies_get_found():
|
||||
cookie = {"name": "tok", "value": "secret123", "domain": "example.com", "path": "/"}
|
||||
result = _run(cookies_group, ["get", "https://example.com", "tok"], cookie)
|
||||
assert result.exit_code == 0
|
||||
assert "secret123" in result.output
|
||||
|
||||
def test_cli_cookies_get_not_found():
|
||||
result = _run(cookies_group, ["get", "https://example.com", "missing"], None)
|
||||
assert result.exit_code != 0
|
||||
assert "not found" in result.output
|
||||
|
||||
def test_cli_cookies_set():
|
||||
result = _run(cookies_group, ["set", "https://example.com", "tok", "val"], None)
|
||||
assert result.exit_code == 0
|
||||
assert "Set cookie" in result.output
|
||||
|
||||
def test_cli_cookies_set_with_options():
|
||||
result = _run(cookies_group, [
|
||||
"set", "https://example.com", "tok", "val",
|
||||
"--secure", "--http-only", "--path", "/app",
|
||||
"--same-site", "lax",
|
||||
], None)
|
||||
assert result.exit_code == 0
|
||||
assert "Set cookie" in result.output
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# page commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Integration tests for cookies.* commands — require a live browser."""
|
||||
import time
|
||||
|
||||
def test_cookies_list_returns_list(browser, http_tab):
|
||||
"""cookies.list returns a list (may be empty on a plain https://example.com)."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
cookies = browser("cookies.list", {})
|
||||
assert isinstance(cookies, list)
|
||||
|
||||
def test_cookies_list_has_required_fields(browser, http_tab):
|
||||
"""Every cookie returned has at least name, domain and path fields."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
# Set a known cookie so the list is non-empty
|
||||
browser("cookies.set", {
|
||||
"url": "https://example.com",
|
||||
"name": "__pytest_field_check",
|
||||
"value": "1",
|
||||
})
|
||||
cookies = browser("cookies.list", {"url": "https://example.com"})
|
||||
assert isinstance(cookies, list)
|
||||
assert len(cookies) > 0
|
||||
for c in cookies:
|
||||
assert "name" in c
|
||||
assert "domain" in c
|
||||
assert "path" in c
|
||||
|
||||
def test_cookies_set_and_list(browser, http_tab):
|
||||
"""Set a cookie and verify it appears in the list."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
cookie_name = "__pytest_set_test"
|
||||
cookie_value = "hello-pytest"
|
||||
|
||||
browser("cookies.set", {
|
||||
"url": "https://example.com",
|
||||
"name": cookie_name,
|
||||
"value": cookie_value,
|
||||
})
|
||||
|
||||
cookies = browser("cookies.list", {"url": "https://example.com"})
|
||||
found = next((c for c in cookies if c.get("name") == cookie_name), None)
|
||||
assert found is not None, f"Cookie '{cookie_name}' not found after set"
|
||||
assert found["value"] == cookie_value
|
||||
|
||||
def test_cookies_get(browser, http_tab):
|
||||
"""Get a single cookie by URL + name."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
name = "__pytest_get_test"
|
||||
value = "get-value-42"
|
||||
|
||||
browser("cookies.set", {"url": "https://example.com", "name": name, "value": value})
|
||||
|
||||
cookie = browser("cookies.get", {"url": "https://example.com", "name": name})
|
||||
assert cookie is not None
|
||||
assert cookie.get("value") == value
|
||||
|
||||
def test_cookies_get_missing_returns_none(browser, http_tab):
|
||||
"""Getting a non-existent cookie returns None."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
cookie = browser("cookies.get", {
|
||||
"url": "https://example.com",
|
||||
"name": "__pytest_no_such_cookie_zzz",
|
||||
})
|
||||
assert cookie is None
|
||||
|
||||
def test_cookies_list_filter_by_domain(browser, http_tab):
|
||||
"""Filtering by domain only returns matching cookies."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
browser("cookies.set", {
|
||||
"url": "https://example.com",
|
||||
"name": "__pytest_domain_filter",
|
||||
"value": "yes",
|
||||
})
|
||||
cookies = browser("cookies.list", {"domain": "example.com"})
|
||||
assert isinstance(cookies, list)
|
||||
for c in cookies:
|
||||
assert "example.com" in c.get("domain", "")
|
||||
|
||||
def test_cookies_list_filter_by_name(browser, http_tab):
|
||||
"""Filtering by name only returns cookies with that name."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
name = "__pytest_name_filter_unique"
|
||||
browser("cookies.set", {"url": "https://example.com", "name": name, "value": "y"})
|
||||
|
||||
cookies = browser("cookies.list", {"name": name})
|
||||
assert isinstance(cookies, list)
|
||||
assert len(cookies) > 0
|
||||
for c in cookies:
|
||||
assert c["name"] == name
|
||||
|
||||
def test_cookies_set_with_secure_flag(browser, http_tab):
|
||||
"""Setting a cookie with secure=True persists the secure attribute."""
|
||||
browser("tabs.active", {"tabId": http_tab["id"]})
|
||||
name = "__pytest_secure_cookie"
|
||||
browser("cookies.set", {
|
||||
"url": "https://example.com",
|
||||
"name": name,
|
||||
"value": "secured",
|
||||
"secure": True,
|
||||
})
|
||||
cookies = browser("cookies.list", {"url": "https://example.com", "name": name})
|
||||
found = next((c for c in cookies if c["name"] == name), None)
|
||||
assert found is not None
|
||||
assert found.get("secure") is True
|
||||
@@ -0,0 +1,35 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
def _load_packager():
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "package_extension.py"
|
||||
spec = importlib.util.spec_from_file_location("package_extension", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_webstore_package_strips_manifest_key(tmp_path):
|
||||
packager = _load_packager()
|
||||
out = packager.package_extension(webstore=True, out=tmp_path / "webstore.zip")
|
||||
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
names = set(zf.namelist())
|
||||
|
||||
assert "key" not in manifest
|
||||
assert "background.js" in names
|
||||
assert "content-dispatch.js" in names
|
||||
assert "content.js" in names
|
||||
assert "icons/icon-128.png" in names
|
||||
|
||||
def test_local_package_keeps_manifest_key(tmp_path):
|
||||
packager = _load_packager()
|
||||
out = packager.package_extension(webstore=False, out=tmp_path / "local.zip")
|
||||
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
|
||||
assert "key" in manifest
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.cli import main
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
|
||||
|
||||
def test_extension_info_cli_renders_capabilities():
|
||||
with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}):
|
||||
result = CliRunner().invoke(main, ["extension", "info"])
|
||||
assert result.exit_code == 0
|
||||
assert "1.2.3" in result.output
|
||||
assert "extension.info" in result.output
|
||||
|
||||
def test_script_runs_raw_commands(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={"count": 2}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert "tabs.count" in result.output
|
||||
send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_session_export_cli_prints_json():
|
||||
with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}):
|
||||
result = CliRunner().invoke(main, ["session", "export", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert '"name": "work"' in result.output
|
||||
|
||||
def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
|
||||
calls = []
|
||||
|
||||
def sender(command, args=None, **kwargs):
|
||||
calls.append((command, args))
|
||||
if command == "tabs.list":
|
||||
return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
|
||||
return {}
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
|
||||
assert calls == [
|
||||
("tabs.list", {}),
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com"}),
|
||||
]
|
||||
|
||||
def test_tabs_tree_command_available():
|
||||
with patch("browser_cli.send_command", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["tabs", "tree"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tabs" in result.output
|
||||
|
||||
def test_doctor_command_reports_connection_failure_cleanly():
|
||||
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
|
||||
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
|
||||
result = CliRunner().invoke(main, ["doctor"])
|
||||
assert result.exit_code == 1
|
||||
assert "Connection" in result.output
|
||||
|
||||
def test_serve_http_no_auth_rejected_on_public_host():
|
||||
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"])
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
|
||||
def test_raw_command_blocks_dangerous_by_default():
|
||||
result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
|
||||
def test_raw_command_allows_dangerous_with_explicit_flag():
|
||||
with patch("browser_cli.send_command", return_value="Example") as send_command:
|
||||
result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_script_blocks_control_without_explicit_flag(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert "blocked by default" in result.output
|
||||
|
||||
def test_script_allows_control_with_explicit_flag(tmp_path: Path):
|
||||
script = tmp_path / "workflow.json"
|
||||
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
|
||||
with patch("browser_cli.send_command", return_value={}) as send_command:
|
||||
result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"])
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_command_policy_categories_and_flags():
|
||||
assert command_category("tabs.list") == "safe"
|
||||
assert command_category("extract.text") == "read-page"
|
||||
assert command_category("dom.click") == "control"
|
||||
assert command_category("storage.get") == "dangerous"
|
||||
assert_command_allowed("tabs.list", CommandPolicy())
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("extract.text", CommandPolicy())
|
||||
assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True))
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True))
|
||||
assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True))
|
||||
Reference in New Issue
Block a user