Compare commits

..

3 Commits

Author SHA1 Message Date
daniel156161 2c38cc8874 docs: link n8n node to public npm package
Testing / remote-protocol-compat (0.9.3) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 55s
Package Extension / package-extension (push) Successful in 25s
Build & Publish Package / publish (push) Successful in 27s
- Point the main README n8n integration section at the published npm package.

- Clarify that the n8n node connects directly to browser-cli serve over the authenticated encrypted TCP protocol.

- Update the n8n node README installation text to use the public community-node package name.
2026-06-19 10:02:36 +02:00
daniel156161 cea8a7e994 feat: add n8n serve node and harden remote access
- Add the n8n community node package with credentials, command mapping, direct serve TCP client, and browser-cli protocol crypto helpers.

- Cover Ed25519 signing, canonical JSON, PQ transport encryption, request mapping, and security behavior with unit tests.

- Harden serve-http with per-address rate limiting, an 8 MB request body cap, and clear warnings when binding plain HTTP beyond loopback.

- Stop one-shot --key overrides from being persisted automatically; document explicit remote trust and keep key-management behind the keys policy tier.

- Make HTML-to-Markdown conversion safer by bounding tree depth and dropping unsafe link/image URL schemes.

- Bump package and extension release metadata to 0.16.3.
2026-06-19 10:00:23 +02:00
daniel156161 7fe0e27fec feat(auth): add interactive key policy editing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 47s
Testing / test (push) Successful in 36s
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve.
- Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes.
- Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected.
- Show policy descriptions in auth keys output so each capability is easier to understand.
- Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates.
- Bump real-browser-cli to 0.16.2 and lock the new questionary dependency.
- Cover local, remote, validation, and policy-category behavior in tests.
2026-06-18 15:02:18 +02:00
35 changed files with 4138 additions and 178 deletions
+8 -25
View File
@@ -37,7 +37,6 @@ terminal / python script / remote client
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`. No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format** **Message format**
Every command is a JSON object: Every command is a JSON object:
```json ```json
{ "id": "uuid", "command": "tabs.list", "args": {} } { "id": "uuid", "command": "tabs.list", "args": {} }
@@ -50,7 +49,6 @@ Every response:
--- ---
## Installation ## Installation
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores). browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
@@ -109,7 +107,6 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
--- ---
## Project structure ## Project structure
```text ```text
browser-cli/ browser-cli/
├── browser_cli/ ├── browser_cli/
@@ -145,7 +142,6 @@ browser-cli/
--- ---
## CLI reference ## CLI reference
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`. During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically. If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
@@ -153,7 +149,6 @@ If exactly one browser instance is connected, commands auto-target it. Use `--br
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
### Navigation (`nav`) ### Navigation (`nav`)
```sh ```sh
# Open a URL (no focus stealing by default) # Open a URL (no focus stealing by default)
browser-cli nav open https://example.com browser-cli nav open https://example.com
@@ -175,7 +170,6 @@ browser-cli nav focus github # focuses first tab whose URL contains "
``` ```
### Search ### Search
Each search command opens the search results in your browser using the same flags as `nav open`. Each search command opens the search results in your browser using the same flags as `nav open`.
```sh ```sh
@@ -199,7 +193,6 @@ browser-cli search so click choices
``` ```
### Tabs ### Tabs
```sh ```sh
browser-cli tabs list # list all open tabs (all windows) browser-cli tabs list # list all open tabs (all windows)
browser-cli tabs count # count all tabs browser-cli tabs count # count all tabs
@@ -227,7 +220,6 @@ browser-cli tabs merge-windows # pull all tabs into the current wi
``` ```
### Tab groups ### Tab groups
```sh ```sh
browser-cli groups list # list all tab groups browser-cli groups list # list all tab groups
browser-cli groups count # count groups browser-cli groups count # count groups
@@ -249,7 +241,6 @@ browser-cli groups move 42 -l # short left alias
``` ```
### Windows ### Windows
```sh ```sh
browser-cli windows list # list all windows browser-cli windows list # list all windows
browser-cli windows open # open a new window browser-cli windows open # open a new window
@@ -259,7 +250,6 @@ browser-cli windows close 1 # close a window
``` ```
### DOM ### DOM
These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`. These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`.
```sh ```sh
@@ -272,7 +262,6 @@ browser-cli dom type "#search" "hello" # type text into an input
``` ```
### Extract ### Extract
```sh ```sh
browser-cli extract links # all <a href> links on the page browser-cli extract links # all <a href> links on the page
browser-cli extract images # all <img> tags (src + alt) browser-cli extract images # all <img> tags (src + alt)
@@ -284,7 +273,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
``` ```
### Sessions ### Sessions
A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared. A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared.
```sh ```sh
@@ -298,7 +286,6 @@ browser-cli session auto-save off
``` ```
### Misc ### Misc
```sh ```sh
browser-cli clients # show connected browser info from the registry browser-cli clients # show connected browser info from the registry
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
@@ -309,7 +296,6 @@ browser-cli completion zsh --script # output raw completion script
``` ```
### Remote control, auth, and gateways ### Remote control, auth, and gateways
```sh ```sh
# On the machine with the browser # On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key browser-cli auth keygen --output ~/.config/browser-cli/client.key
@@ -334,22 +320,24 @@ browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
``` ```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint. Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Use `browser-cli remote trust ENDPOINT KEY` to remember a key for later calls. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
#### n8n integration
The n8n community node is published as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli) on npm. It talks directly to a remote `browser-cli serve` endpoint over the same Ed25519-authenticated, ML-KEM-encrypted TCP protocol as the CLI remote client. Install it from n8n's Community Nodes UI, run `browser-cli serve` on the browser machine, paste the client key into the node credential, and drive tabs/DOM/extraction/raw commands from a workflow. See [`n8n-nodes-browser-cli/README.md`](n8n-nodes-browser-cli/README.md).
#### Security model #### Security model
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts. - **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`, `keys`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` when adding a key, or change it later with `auth policy <pubkey|name> …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy.
- **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` (works locally and over `--remote`); `auth keys` shows each key's policy. - **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own.
- **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser. - **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser.
- **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address. - **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address.
- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default), but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there. If you must expose it beyond loopback, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted. - **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use.
For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection. For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection.
--- ---
## Python SDK ## Python SDK
```python ```python
from browser_cli import AsyncBrowserCLI, BrowserCLI from browser_cli import AsyncBrowserCLI, BrowserCLI
@@ -485,7 +473,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
``` ```
**Error handling** **Error handling**
```python ```python
from browser_cli import BrowserCLI, BrowserNotConnected from browser_cli import BrowserCLI, BrowserNotConnected
@@ -517,7 +504,6 @@ if isinstance(counts, BrowserCounts):
--- ---
## Example scripts ## Example scripts
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management. See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
```sh ```sh
@@ -528,7 +514,6 @@ bash examples/demo.sh
--- ---
## Development ## Development
```sh ```sh
npm ci npm ci
npm run check:extension # type-check, build extension bundles, syntax-check bundle npm run check:extension # type-check, build extension bundles, syntax-check bundle
@@ -569,7 +554,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
--- ---
## Limitations ## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`. - **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely. - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
@@ -578,7 +562,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
--- ---
## License ## License
PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE). PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE).
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer. Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
+2
View File
@@ -24,6 +24,7 @@ from browser_cli.auth.keys import (
load_authorized_keys_with_policies, load_authorized_keys_with_policies,
load_private_key, load_private_key,
public_key_hex, public_key_hex,
set_authorized_key_policy,
) )
from browser_cli.auth.pq import ( from browser_cli.auth.pq import (
new_nonce, new_nonce,
@@ -66,6 +67,7 @@ __all__ = [
"pq_kex_server_decapsulate", "pq_kex_server_decapsulate",
"pq_kex_server_keypair", "pq_kex_server_keypair",
"public_key_hex", "public_key_hex",
"set_authorized_key_policy",
"sign", "sign",
"verify", "verify",
] ]
+34
View File
@@ -89,3 +89,37 @@ def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: lis
with open(path, "a", encoding="utf-8") as file: with open(path, "a", encoding="utf-8") as file:
file.write(line) file.write(line)
return True return True
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
"""Update the per-key policy for a trusted key.
``identifier`` may be the full public key or an exact key name. ``categories``
is written as the ``allow:`` token; ``None`` removes the token so the key uses
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
no key matched, and raises ``ValueError`` for ambiguous names.
"""
if not path.exists():
return None
wanted = identifier.strip()
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
matches: list[tuple[int, str, str, str]] = []
for index, line in enumerate(lines):
parsed = _parse_authorized_line(line)
if parsed is None:
continue
pubkey, name, _cats = parsed
if pubkey.lower() == wanted.lower() or (name and name == wanted):
newline = "\n" if line.endswith("\n") else ""
matches.append((index, pubkey, name, newline))
if not matches:
return None
if len(matches) > 1:
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
index, pubkey, name, newline = matches[0]
lines[index] = format_authorized_line(pubkey, name, categories) + newline
path.write_text("".join(lines), encoding="utf-8")
return pubkey, name
-4
View File
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
msg["accept_encoding"] = transport.client_accept_encoding() msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint) key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
private_key = load_private_key(key_spec) private_key = load_private_key(key_spec)
if key is not None:
remote_registry.save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS: if not route_profile and command not in NO_ROUTE_COMMANDS:
@@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil
msg["accept_encoding"] = transport.client_accept_encoding() msg["accept_encoding"] = transport.client_accept_encoding()
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint) key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
private_key = await asyncio.to_thread(load_private_key, key_spec) private_key = await asyncio.to_thread(load_private_key, key_spec)
if key is not None:
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
route_profile = requested_profile route_profile = requested_profile
if not route_profile and command not in NO_ROUTE_COMMANDS: if not route_profile and command not in NO_ROUTE_COMMANDS:
+1
View File
@@ -80,6 +80,7 @@ DANGEROUS_PREFIXES = (
KEY_COMMANDS = { KEY_COMMANDS = {
"browser-cli.auth.keys", "browser-cli.auth.keys",
"browser-cli.auth.trust", "browser-cli.auth.trust",
"browser-cli.auth.policy",
} }
@dataclass(frozen=True) @dataclass(frozen=True)
+253 -1
View File
@@ -98,6 +98,88 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control,
else: else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}") console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("policy")
@click.argument("identifier", required=False)
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
@command_policy_options
@click.pass_context
@handle_errors
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Change a trusted key's per-key policy.
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
an interactive terminal to pick a key first, then edit the policy with real
checkbox prompts. Use --safe for an explicit safe-only override,
--server-default to remove the override, or one or more --allow-* flags for
scriptable/non-interactive usage.
"""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
if modes > 1:
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
sys.exit(1)
is_interactive = click.get_text_stream("stdin").isatty()
current_categories = None
if not identifier:
if not is_interactive:
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
sys.exit(1)
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
identifier = entry.get("pubkey") or entry.get("name") or ""
current_categories = entry.get("allow")
elif modes == 0 and is_interactive:
entry = _find_policy_entry(ctx, keys_file, identifier)
current_categories = entry.get("allow") if entry else None
if server_default:
categories = None
elif safe_only:
categories = []
elif explicit_allow:
categories = command_categories_from_options(
allow_read_page=allow_read_page, allow_control=allow_control,
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
)
else:
if not is_interactive:
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
sys.exit(1)
categories = _prompt_policy_categories(identifier, current_categories)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.policy",
args={"identifier": identifier, "allow": categories},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
name = (result or {}).get("name") or ""
pubkey = (result or {}).get("pubkey") or identifier
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
try:
updated = set_authorized_key_policy(path, identifier, categories)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
sys.exit(1)
if updated is None:
console.print(f"[red]Trusted key not found:[/red] {identifier}")
sys.exit(1)
pubkey, name = updated
label = f" ({name})" if name else ""
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
console.print(f" File: {path}")
@auth_group.command("show") @auth_group.command("show")
@click.option( @click.option(
"--key", "--key",
@@ -170,11 +252,164 @@ def cmd_auth_keys(ctx, keys_file):
table.add_column("Name") table.add_column("Name")
table.add_column("Public Key") table.add_column("Public Key")
table.add_column("Policy") table.add_column("Policy")
table.add_column("Description")
for entry in entries: for entry in entries:
name = entry.get("name") or "[dim]—[/dim]" name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""), _policy_label(entry.get("allow"))) allow = entry.get("allow")
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
console.print(table) console.print(table)
def _load_policy_entries(ctx, keys_file):
"""Load trusted-key entries for interactive selection."""
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
return send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
) or []
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
def _find_policy_entry(ctx, keys_file, identifier: str):
"""Find the current key entry so the checkbox prompt can preselect values."""
wanted = identifier.strip()
for entry in _load_policy_entries(ctx, keys_file):
pubkey = str(entry.get("pubkey") or "")
name = str(entry.get("name") or "")
if pubkey.lower() == wanted.lower() or (name and name == wanted):
return entry
return None
def _prompt_key_entry(entries):
"""Interactive checkbox flow step 1: choose which key to edit."""
if not entries:
raise click.ClickException("no trusted keys found")
import questionary
choices = []
for entry in entries:
name = entry.get("name") or "unnamed key"
pubkey = entry.get("pubkey") or ""
policy = _plain_policy_label(entry.get("allow"))
choices.append(questionary.Choice(
title=f"{name} [{policy}] {pubkey[:12]}{pubkey[-8:]}",
value=entry,
))
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
if selected is None:
raise click.ClickException("cancelled")
return selected
def _prompt_policy_categories(identifier: str, current_categories=None):
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
import questionary
checked = set(current_categories or [])
special_checked = {
"__server_default__": current_categories is None,
"__safe__": current_categories == [],
"__all__": isinstance(current_categories, list) and "all" in current_categories,
}
choices = [
questionary.Choice(
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
value="read-page",
checked="read-page" in checked,
),
questionary.Choice(
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
value="control",
checked="control" in checked,
),
questionary.Choice(
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
value="dangerous",
checked="dangerous" in checked,
),
questionary.Choice(
title="keys — admin access to key management over --remote: auth keys/trust/policy",
value="keys",
checked="keys" in checked,
),
questionary.Separator(),
questionary.Choice(
title="all — allow everything",
value="__all__",
checked=special_checked["__all__"],
),
questionary.Choice(
title="safe — explicit safe-only override",
value="__safe__",
checked=special_checked["__safe__"],
),
questionary.Choice(
title="server default — remove per-key override and inherit server policy",
value="__server_default__",
checked=special_checked["__server_default__"],
),
]
selected = questionary.checkbox(
f"Policy for {identifier}",
choices=choices,
instruction="(space to toggle, enter to save)",
).ask()
if selected is None:
raise click.ClickException("cancelled")
return _parse_checkbox_policy_selection(selected)
def _parse_checkbox_policy_selection(selected):
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
if len(special) > 1 or (special and normal):
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
if special == ["__server_default__"]:
return None
if special == ["__safe__"]:
return []
if special == ["__all__"]:
return ["all"]
return normal
def _parse_policy_selection(raw: str):
value = raw.strip().lower()
if value in {"default", "server-default", "server default", "inherit", "none"}:
return None
if value in {"safe", "safe-only", ""}:
return []
if value == "all":
return ["all"]
number_map = {
"1": "read-page",
"2": "control",
"3": "dangerous",
"4": "keys",
}
valid = {"read-page", "control", "dangerous", "keys"}
categories = []
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
category = number_map.get(token, token)
if category == "all":
return ["all"]
if category not in valid:
raise click.ClickException(f"unknown policy choice: {token}")
if category not in categories:
categories.append(category)
return categories
def _plain_policy_label(categories) -> str:
"""Plain-text policy label for interactive prompt titles."""
if categories is None:
return "server default"
if "all" in categories:
return "all"
return ", ".join(categories) if categories else "safe"
def _policy_label(categories) -> str: def _policy_label(categories) -> str:
"""Render an authorized_keys ``allow:`` token for display.""" """Render an authorized_keys ``allow:`` token for display."""
if categories is None: if categories is None:
@@ -182,3 +417,20 @@ def _policy_label(categories) -> str:
if "all" in categories: if "all" in categories:
return "[yellow]all[/yellow]" return "[yellow]all[/yellow]"
return ", ".join(categories) if categories else "safe" return ", ".join(categories) if categories else "safe"
def _policy_description(categories) -> str:
"""Human-readable explanation for a policy category list."""
if categories is None:
return "Inherits the policy from browser-cli serve"
if "all" in categories:
return "Full access: page reads, browser control, dangerous commands, key admin"
if not categories:
return "Safe status/list commands only"
descriptions = {
"read-page": "read page content",
"control": "control browser/tabs/page input",
"dangerous": "run high-risk commands",
"keys": "manage trusted keys remotely",
}
return "; ".join(descriptions.get(category, category) for category in categories)
+38 -8
View File
@@ -11,9 +11,13 @@ from rich.console import Console
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options from browser_cli.commands import command_policy_from_options, command_policy_options
from browser_cli.serve.security import RateLimiter
console = Console() console = Console()
# Hard cap on request body size so a bogus Content-Length can't exhaust memory.
MAX_BODY_BYTES = 8 * 1024 * 1024
def _is_loopback(host: str) -> bool: def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"} return host in {"127.0.0.1", "localhost", "::1"}
@@ -21,6 +25,7 @@ class _Handler(BaseHTTPRequestHandler):
client: BrowserCLI client: BrowserCLI
token: str | None = None token: str | None = None
policy: CommandPolicy = CommandPolicy() policy: CommandPolicy = CommandPolicy()
rate_limiter: RateLimiter | None = None
def _authorized(self) -> bool: def _authorized(self) -> bool:
if self.token is None: if self.token is None:
@@ -37,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
self._send(401, {"error": "missing or invalid token"}) self._send(401, {"error": "missing or invalid token"})
return False return False
def _within_rate_limit(self) -> bool:
if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]):
return True
self._send(429, {"error": "rate limit exceeded; slow down and retry"})
return False
def _send(self, status: int, payload): def _send(self, status: int, payload):
raw = json.dumps(payload, default=str).encode("utf-8") raw = json.dumps(payload, default=str).encode("utf-8")
self.send_response(status) self.send_response(status)
@@ -48,7 +59,10 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
path = urlparse(self.path).path path = urlparse(self.path).path
try: try:
if path != "/health" and not self._require_auth(): if path != "/health":
if not self._require_auth():
return
if not self._within_rate_limit():
return return
if path == "/tabs": if path == "/tabs":
self._send(200, [t.__dict__ for t in self.client.tabs.list()]) self._send(200, [t.__dict__ for t in self.client.tabs.list()])
@@ -64,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
path = urlparse(self.path).path path = urlparse(self.path).path
try: try:
length = int(self.headers.get("Content-Length", "0")) if path != "/command":
body = json.loads(self.rfile.read(length) or b"{}") self._send(404, {"error": "not found"})
if path == "/command": return
if not self._require_auth(): if not self._require_auth():
return return
if not self._within_rate_limit():
return
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY_BYTES:
self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"})
return
body = json.loads(self.rfile.read(length) or b"{}")
command = body.get("command") command = body.get("command")
assert_command_allowed(command, self.policy) assert_command_allowed(command, self.policy)
self._send(200, {"result": self.client.command(command, body.get("args") or {})}) self._send(200, {"result": self.client.command(command, body.get("args") or {})})
else:
self._send(404, {"error": "not found"})
except PermissionError as exc: except PermissionError as exc:
self._send(403, {"error": str(exc)}) self._send(403, {"error": str(exc)})
except Exception as exc: except Exception as exc:
@@ -90,21 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
@click.option("--key", default=None, help="Remote auth key spec") @click.option("--key", default=None, help="Remote auth key spec")
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)") @click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)") @click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)")
@command_policy_options @command_policy_options
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all): def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command). """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
Auth is enabled by default. Pass the printed token as either Auth is enabled by default. Pass the printed token as either
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``. ``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
This gateway speaks plain HTTP — the token is sent in clear text. Keep it on
loopback, or put a TLS-terminating reverse proxy in front before exposing it.
""" """
if no_auth and not _is_loopback(host): if no_auth and not _is_loopback(host):
raise click.ClickException("--no-auth is only allowed on loopback hosts") raise click.ClickException("--no-auth is only allowed on loopback hosts")
if not _is_loopback(host):
console.print(
"[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the "
"token travels in clear text. Put a TLS-terminating reverse proxy in front, or use "
"[bold]browser-cli serve[/bold] (encrypted) instead."
)
auth_token = None if no_auth else (token or secrets.token_urlsafe(32)) auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all) policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
handler = type( handler = type(
"BrowserCLIHTTPHandler", "BrowserCLIHTTPHandler",
(_Handler,), (_Handler,),
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy}, {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter},
) )
server = ThreadingHTTPServer((host, port), handler) server = ThreadingHTTPServer((host, port), handler)
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]") console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
+8 -2
View File
@@ -20,11 +20,17 @@ def _auth_0_9_3(msg: dict) -> dict:
pk = msg.get("pubkey") pk = msg.get("pubkey")
if isinstance(pk, str) and pk: if isinstance(pk, str) and pk:
changed["pubkey"] = pk.lower() changed["pubkey"] = pk.lower()
if msg.get("command") == "browser-cli.auth.trust": if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
args = msg.get("args") or {} args = msg.get("args") or {}
trust_pk = args.get("pubkey") trust_pk = args.get("pubkey")
identifier = args.get("identifier")
patched = dict(args)
if isinstance(trust_pk, str) and trust_pk: if isinstance(trust_pk, str) and trust_pk:
changed["args"] = {**args, "pubkey": trust_pk.lower()} patched["pubkey"] = trust_pk.lower()
if isinstance(identifier, str) and identifier and len(identifier) == 64:
patched["identifier"] = identifier.lower()
if patched != args:
changed["args"] = patched
return {**msg, **changed} if changed else msg return {**msg, **changed} if changed else msg
+1 -1
View File
@@ -44,7 +44,7 @@ DEFAULT_TRANSPORT_THRESHOLD = 512
# authenticated connection for multiple commands instead of re-handshaking. # authenticated connection for multiple commands instead of re-handshaking.
REMOTE_SESSION_IDLE_TIMEOUT = 30 REMOTE_SESSION_IDLE_TIMEOUT = 30
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"} NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"] GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
PAGEABLE_COMMANDS = { PAGEABLE_COMMANDS = {
+25 -3
View File
@@ -11,6 +11,13 @@ class _HtmlNode:
self.text = text self.text = text
self.children = [] self.children = []
# Cap how deep the parsed tree may nest. Hostile page content (thousands of
# nested elements) would otherwise blow Python's recursion limit in the
# depth-first render walkers below. Bounding here protects every walker at once.
# 200 levels is far beyond any real document; deeper content is flattened, not
# dropped (its text still reaches the output).
_MAX_TREE_DEPTH = 200
class _HtmlTreeBuilder(HTMLParser): class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"} _VOID_TAGS = {"br", "hr", "img"}
@@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
def handle_starttag(self, tag, attrs): def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs)) node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node) self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS: # Only descend while under the depth cap; beyond it, children of this node
# attach to the current (capped) parent — flattened but preserved.
if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH:
self._stack.append(node) self._stack.append(node)
def handle_startendtag(self, tag, attrs): def handle_startendtag(self, tag, attrs):
@@ -57,6 +66,14 @@ def _collapse_blank_lines(value):
def _escape_markdown(text): def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text) return re.sub(r"([\\`[\]])", r"\\\1", text)
# Schemes that are dangerous if the produced markdown is later rendered as HTML
# by a downstream consumer. The output is plain text here, but neutralising them
# keeps the converter from laundering an XSS payload through to such a consumer.
_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE)
def _safe_url(url):
return "" if _UNSAFE_URL_SCHEME.match(url or "") else url
def _escape_table_cell(text): def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip() return text.replace("|", r"\|").replace("\n", " ").strip()
@@ -86,14 +103,14 @@ def _inline_text(node):
if tag == "br": if tag == "br":
return "\n" return "\n"
if tag == "img": if tag == "img":
src = node.attrs.get("src") or "" src = _safe_url(node.attrs.get("src") or "")
alt = _normalize_text(node.attrs.get("alt") or "") alt = _normalize_text(node.attrs.get("alt") or "")
if not src: if not src:
return "" return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})" return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a": if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children)) text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or "" href = _safe_url(node.attrs.get("href") or "")
return f"[{text or href}]({href})" if href else text return f"[{text or href}]({href})" if href else text
if tag == "code": if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children)) text = _normalize_inline("".join(_inline_text(child) for child in node.children))
@@ -235,5 +252,10 @@ def _block_to_markdown(node):
def convert_html_to_markdown(html, clean_markdown_output): def convert_html_to_markdown(html, clean_markdown_output):
parser = _HtmlTreeBuilder() parser = _HtmlTreeBuilder()
parser.feed(html or "") parser.feed(html or "")
try:
markdown = _block_to_markdown(parser.root) markdown = _block_to_markdown(parser.root)
except RecursionError:
# The depth cap should prevent this, but never let hostile page content
# crash the caller: fall back to a flat, tag-stripped text extraction.
markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or ""))
return clean_markdown_output(markdown) return clean_markdown_output(markdown)
+49 -11
View File
@@ -54,6 +54,9 @@ class ServeControlMixin:
if self.command == "browser-cli.auth.trust": if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg) return await self._handle_trust(msg)
if self.command == "browser-cli.auth.policy":
return await self._handle_policy(msg)
return False return False
async def _handle_trust(self, msg: dict) -> bool: async def _handle_trust(self, msg: dict) -> bool:
@@ -62,7 +65,6 @@ class ServeControlMixin:
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file") log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True return True
from browser_cli.auth import add_authorized_key from browser_cli.auth import add_authorized_key
from browser_cli.serve.security import policy_from_categories
args = msg.get("args") or {} args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "") pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "") name = str(args.get("name") or "")
@@ -71,18 +73,54 @@ class ServeControlMixin:
await self.send_error("invalid pubkey: expected 64 lowercase hex characters") await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label) log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
return True return True
if categories is not None: if not await self._validate_categories(categories):
if not isinstance(categories, list):
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return True
try:
policy_from_categories(categories) # validate before persisting
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
return True return True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories) added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command) await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label) log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
return True return True
async def _handle_policy(self, msg: dict) -> bool:
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import set_authorized_key_policy
args = msg.get("args") or {}
identifier = str(args.get("identifier") or "")
categories = args.get("allow")
if not identifier.strip():
await self.send_error("missing key identifier")
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
return True
if not await self._validate_categories(categories):
return True
try:
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
return True
if updated is None:
await self.send_error(f"trusted key not found: {identifier}")
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
return True
pubkey, name = updated
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
return True
async def _validate_categories(self, categories) -> bool:
if categories is not None and not isinstance(categories, list):
await self.send_error("invalid allow: expected a list of category strings")
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
return False
if categories is not None:
from browser_cli.serve.security import policy_from_categories
try:
policy_from_categories(categories) # validate before persisting
except ValueError as exc:
await self.send_error(str(exc))
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
return False
return True
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.16.0", "version": "0.16.3",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
test-dist/
*.tsbuildinfo
+89
View File
@@ -0,0 +1,89 @@
# n8n-nodes-browser-cli
An [n8n](https://n8n.io) community node, published on npm as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli), that controls a **real, visible browser**
from your workflows via [browser-cli](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp).
browser-cli drives a running browser through a native-messaging host and a
browser extension — it **cannot be installed inside the n8n container**. So this
node speaks the `browser-cli serve` protocol **directly**: a length-framed TCP
connection authenticated with an Ed25519 key, with request/response bodies
encrypted end-to-end via an ML-KEM-768 (post-quantum) key exchange — the same
wire protocol the `browser-cli --remote` client uses.
```
n8n workflow ──TCP (Ed25519 + ML-KEM-768)──▶ browser-cli serve (remote host) ──▶ browser
```
Because the payloads are end-to-end encrypted, the endpoint is safe to expose on
an untrusted network without a TLS proxy in front of it.
## Remote setup (on the browser machine)
Install browser-cli, register the extension, trust your n8n key, then start
`serve` opening exactly the command tiers you need (it is **safe-only by default**):
```bash
uv tool install real-browser-cli
browser-cli install brave # one-time: register the extension/native host
# On the n8n side, generate a client key and print its public key:
browser-cli auth keygen -o n8n_key.pem
# On the browser machine, trust that public key (optionally scope its policy):
browser-cli auth trust <pubkey-hex> --allow-control
# Expose the browser. Open only what your workflow needs:
browser-cli serve --host 0.0.0.0 --port 8765 \
--authorized-keys ~/.browser_cli/authorized_keys --allow-read-page --allow-control
```
Paste the contents of `n8n_key.pem` into the n8n credential.
## n8n credential — "Browser CLI API"
| Field | Description |
|-------|-------------|
| Host | host of the `serve` endpoint, e.g. `browser-host.example` |
| Port | `serve` TCP port (default `8765`) |
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
| Browser Alias | optional `_route` target — required if the endpoint serves multiple browsers |
| Use TLS | wrap the connection in TLS (only for a TLS-terminating proxy; the protocol is already encrypted) |
| Ignore SSL Issues | when TLS is on, accept a self-signed proxy cert |
## Operations
Every operation maps to one raw browser-cli command, each subject to the server
policy tier noted below.
| Resource | Operation | Command | Server flag needed |
|----------|-----------|---------|--------------------|
| Tab | List | `tabs.list` | safe (default) |
| Tab | Open | `navigate.open` | `--allow-control` |
| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` |
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
| Page | Get Info | `page.info` | safe (default) |
| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` |
| DOM | Query | `dom.query` | `--allow-read-page` |
| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` |
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
| Client | List | `clients.list` | safe (default) |
| Command | Execute | any command name + JSON args | per command |
| Gateway | Health | pings with `tabs.list` | safe (default) |
**Command → Execute** is the escape hatch: any command string the server policy
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
Use it for anything the typed operations don't cover.
> Note: `serve` returns the **raw** command result (no SDK post-processing).
> `extract.markdown` therefore returns the page payload as the extension hands it
> back, not the CLI's rendered Markdown. For clean text use **Extract Text**.
## Develop / build
```bash
cd n8n-nodes-browser-cli
npm install # add --ignore-scripts if a transitive native dep
# (isolated-vm) fails to compile on your Node version
npm test # pure unit tests: command mapping + crypto known-answer vectors
npm run build # tsc -> dist/, copies the icon
```
Install the published package in n8n as a [community node](https://docs.n8n.io/integrations/community-nodes/installation/) using the package name `n8n-nodes-browser-cli`, or symlink `dist/` into `~/.n8n/custom` for local testing.
## License
PolyForm Noncommercial License 1.0.0 — same as browser-cli.
@@ -0,0 +1,85 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
/**
* Credentials for a raw `browser-cli serve` endpoint.
*
* browser-cli cannot be installed inside n8n, so the node talks directly to a
* `serve` instance running on the machine that drives the browser. Start it
* there and trust this client's key:
*
* browser-cli auth keygen # on the n8n side, prints a PEM
* browser-cli auth trust <pubkey> --allow-control # on the serve side
* browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.browser_cli/authorized_keys
*
* The connection is authenticated with the Ed25519 private key below and the
* request/response bodies are encrypted with an ML-KEM-768 (post-quantum) key
* exchange, so it is safe to expose over an untrusted network without TLS.
* Leave the key empty only for a loopback `serve --no-auth` instance.
*/
export class BrowserCliApi implements ICredentialType {
name = 'browserCliApi';
displayName = 'Browser CLI API';
documentationUrl = 'https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp';
// The serve protocol is raw TCP, not HTTP, so the declarative HTTP test does
// not apply — testing is done by the node method of this name, which runs a
// real authenticated handshake against the endpoint.
testedBy = 'browserCliApiTest';
properties: INodeProperties[] = [
{
displayName: 'Host',
name: 'host',
type: 'string',
default: '127.0.0.1',
placeholder: 'browser-host.example',
required: true,
description: 'Host of the remote `browser-cli serve` endpoint',
},
{
displayName: 'Port',
name: 'port',
type: 'number',
default: 8765,
required: true,
description: 'TCP port the `serve` endpoint listens on',
},
{
displayName: 'Ed25519 Private Key',
name: 'privateKey',
type: 'string',
typeOptions: { password: true, rows: 4 },
default: '',
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
description:
'PKCS8 PEM Ed25519 private key (from `browser-cli auth keygen`) whose public key is trusted by the serve endpoint. Leave empty only for a loopback `serve --no-auth` instance.',
},
{
displayName: 'Browser Alias',
name: 'browser',
type: 'string',
default: '',
placeholder: 'main',
description:
'Optional browser alias to route to (the serve `_route` target). Required when the serve endpoint exposes multiple browser instances; leave empty for a single-browser serve.',
},
{
displayName: 'Use TLS',
name: 'tls',
type: 'boolean',
default: false,
description:
'Whether to wrap the connection in TLS. Only needed when `serve` sits behind a TLS-terminating proxy; the protocol is already end-to-end encrypted via post-quantum key exchange.',
},
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
default: false,
displayOptions: { show: { tls: [true] } },
description: 'Whether to connect even when the TLS certificate cannot be verified (e.g. a self-signed proxy)',
},
];
}
@@ -0,0 +1,382 @@
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
IExecuteFunctions,
IDataObject,
IDisplayOptions,
INodeCredentialTestResult,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { buildCommand, type CommandParams } from './request';
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
/** Only show a property for the given resource/operation combinations. */
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
return { resource: [resource], operation: operations };
}
export class BrowserCli implements INodeType {
description: INodeTypeDescription = {
displayName: 'Browser CLI',
name: 'browserCli',
icon: 'file:browserCli.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
defaults: { name: 'Browser CLI' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,
credentials: [{ name: 'browserCliApi', required: true }],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Tab', value: 'tab' },
{ name: 'Page', value: 'page' },
{ name: 'DOM', value: 'dom' },
{ name: 'Client', value: 'client' },
{ name: 'Command', value: 'command' },
{ name: 'Gateway', value: 'gateway' },
],
default: 'tab',
},
// --- Tab operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['tab'] } },
options: [
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
],
default: 'list',
},
// --- Page operations --------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['page'] } },
options: [
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
],
default: 'extractText',
},
// --- DOM operations ---------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['dom'] } },
options: [
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
],
default: 'query',
},
// --- Client operations ------------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['client'] } },
options: [
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
],
default: 'list',
},
// --- Command operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['command'] } },
options: [
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
],
default: 'execute',
},
// --- Gateway operations -----------------------------------------------
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['gateway'] } },
options: [
{ name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' },
],
default: 'health',
},
// --- Shared parameter fields -----------------------------------------
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
required: true,
placeholder: 'https://example.com',
displayOptions: { show: showFor('tab', ['open']) },
},
{
displayName: 'Focus Tab',
name: 'focus',
type: 'boolean',
default: false,
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
displayOptions: { show: showFor('tab', ['open']) },
},
{
displayName: 'Close By',
name: 'mode',
type: 'options',
default: 'ids',
options: [
{ name: 'Tab IDs', value: 'ids' },
{ name: 'Inactive Tabs', value: 'inactive' },
{ name: 'Duplicate Tabs', value: 'duplicates' },
],
displayOptions: { show: showFor('tab', ['close']) },
},
{
displayName: 'Tab IDs',
name: 'tabIds',
type: 'string',
default: '',
placeholder: '123, 456',
description: 'Comma/space separated tab IDs, or a JSON array',
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } },
},
{
displayName: 'Selector',
name: 'selector',
type: 'string',
default: '',
placeholder: '#main, .content',
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
displayOptions: {
show: {
resource: ['dom', 'page'],
operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'],
},
},
},
{
displayName: 'Text',
name: 'text',
type: 'string',
default: '',
required: true,
displayOptions: { show: showFor('dom', ['type']) },
},
{
displayName: 'JavaScript',
name: 'code',
type: 'string',
typeOptions: { rows: 4 },
default: '',
required: true,
placeholder: 'return document.title',
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
displayOptions: { show: showFor('dom', ['eval']) },
},
{
displayName: 'Tab ID',
name: 'tabId',
type: 'number',
default: 0,
description: 'Target tab ID. Leave 0 for the active tab.',
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
},
{
displayName: 'Command',
name: 'command',
type: 'string',
default: '',
required: true,
placeholder: 'tabs.list',
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
displayOptions: { show: showFor('command', ['execute']) },
},
{
displayName: 'Arguments',
name: 'args',
type: 'json',
default: '{}',
description: 'JSON object of command arguments',
displayOptions: { show: showFor('command', ['execute']) },
},
],
};
methods = {
credentialTest: {
// Runs a real authenticated handshake so "Test" in the credential UI
// reflects the actual serve protocol, not a stand-in HTTP request.
async browserCliApiTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
options.timeoutMs = 10_000;
try {
const response = await sendServeCommand(options, 'clients.list', {});
if (response && response.success === false) {
const message = String(response.error ?? 'serve returned an error');
// A rejected key is a real credential failure; any other server-side
// message means the handshake + auth already succeeded.
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
return { status: 'Error', message };
}
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
}
return { status: 'OK', message: 'Connected to browser-cli serve' };
} catch (error) {
return { status: 'Error', message: (error as Error).message };
}
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const connectOptions = connectOptionsFromCredentials(
(await this.getCredentials('browserCliApi')) as IDataObject,
);
for (let i = 0; i < items.length; i++) {
try {
const resource = this.getNodeParameter('resource', i) as string;
const operation = this.getNodeParameter('operation', i) as string;
const params = collectParams(this, resource, operation, i);
const { command, args } = buildCommand(resource, operation, params);
const response = await sendServeCommand(connectOptions, command, args);
if (response && response.success === false) {
throw new Error(String(response.error || 'serve command failed'));
}
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
const rows = Array.isArray(data) ? data : [data];
for (const row of rows) {
returnData.push({
json: isObject(row) ? (row as IDataObject) : { result: row },
pairedItem: { item: i },
});
}
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
continue;
}
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
}
}
return [returnData];
}
}
function isObject(value: unknown): boolean {
return typeof value === 'object' && value !== null;
}
/** Map decrypted credential fields to serve connection options. */
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
return {
host: String(creds.host || '127.0.0.1'),
port: Number(creds.port || 8765),
tls: Boolean(creds.tls),
rejectUnauthorized: !creds.allowUnauthorizedCerts,
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
route: creds.browser ? String(creds.browser) : null,
};
}
/** Read the UI fields relevant to this operation into a plain params object. */
function collectParams(
ctx: IExecuteFunctions,
resource: string,
operation: string,
i: number,
): CommandParams {
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
const key = `${resource}:${operation}`;
switch (key) {
case 'command:execute': {
const raw = get('args', {});
let args: Record<string, unknown> = {};
if (typeof raw === 'string') {
const trimmed = raw.trim();
args = trimmed ? JSON.parse(trimmed) : {};
} else if (isObject(raw)) {
args = raw as Record<string, unknown>;
}
return { command: get('command'), args };
}
case 'tab:open':
return { url: get('url'), focus: get('focus', false) };
case 'tab:close':
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
case 'tab:getHtml':
return { tabId: get('tabId', 0) };
case 'dom:query':
case 'dom:click':
return { selector: get('selector', '') };
case 'dom:type':
return { selector: get('selector', ''), text: get('text', '') };
case 'dom:eval':
return { code: get('code', ''), tabId: get('tabId', 0) };
case 'page:extractText':
case 'page:extractLinks':
case 'page:extractImages':
case 'page:extractHtml':
case 'page:extractMarkdown':
return { selector: get('selector', '') };
default:
return {};
}
}
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title>browser-cli icon</title>
<defs>
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f766e" />
<stop offset="1" stop-color="#0f172a" />
</linearGradient>
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f8fafc" />
<stop offset="1" stop-color="#cbd5e1" />
</linearGradient>
</defs>
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,302 @@
/**
* Pure protocol + crypto for talking to a raw `browser-cli serve` endpoint.
*
* This module imports nothing from n8n and only touches Node's `crypto` plus a
* lazily-loaded ML-KEM implementation, so it can be unit-tested with a plain
* esbuild/node toolchain. The socket mechanics live in `serveClient.ts`.
*
* The wire protocol mirrors the Python client in `browser_cli/remote` and
* `browser_cli/auth`:
*
* 1. Server sends a framed `challenge` JSON: {nonce, min_client_version, pq_kex?}.
* 2. Client replies with one framed message. With a private key this is an
* Ed25519 signature over `nonce + sha256(canonical_json(msg))`, optionally
* bound to an ML-KEM-768 shared secret. When the server offers `pq_kex`
* (it always does once authorized_keys is set) the request body is also
* ChaCha20-Poly1305 encrypted under that secret.
* 3. Server replies with one framed payload, encrypted the same way.
*
* Every value the client signs must serialize byte-for-byte like Python's
* `json.dumps(sort_keys=True, separators=(",", ":"))` with `ensure_ascii=True`
* or the signature is rejected see `canonicalJson` and `protocol.test.ts`.
*/
import {
createPrivateKey,
createPublicKey,
createHash,
createHmac,
createCipheriv,
createDecipheriv,
randomBytes,
sign as nodeSign,
type KeyObject,
} from 'node:crypto';
export const PQ_KEX_ALG = 'ML-KEM-768';
export const PQ_TRANSPORT_ALG = 'ML-KEM-768+ChaCha20Poly1305';
/** Auth-protocol fields that are never part of the signed canonical payload. */
const AUTH_FIELDS = new Set(['pubkey', 'sig', 'pq_kex', 'encrypted']);
// --- Framing ---------------------------------------------------------------
/** Prefix `payload` with browser-cli's 4-byte little-endian length header. */
export function frame(payload: Buffer): Buffer {
const header = Buffer.allocUnsafe(4);
header.writeUInt32LE(payload.length, 0);
return Buffer.concat([header, payload]);
}
// --- Canonical JSON (matches Python json.dumps sort_keys + ensure_ascii) ----
/** Deterministic JSON string identical to the Python signing canonicalization. */
export function canonicalJson(value: unknown): string {
return encode(value);
}
function encode(value: unknown): string {
if (value === null) return 'null';
const type = typeof value;
if (type === 'string') return encodeString(value as string);
if (type === 'boolean') return value ? 'true' : 'false';
if (type === 'number') {
const n = value as number;
if (!Number.isFinite(n)) throw new Error('cannot encode non-finite number');
// Integers match Python exactly; non-integers are rare in args and fall
// back to JS formatting (documented limitation, see protocol.test.ts).
return Number.isInteger(n) ? String(n) : JSON.stringify(n);
}
if (Array.isArray(value)) return '[' + value.map(encode).join(',') + ']';
if (type === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj)
.filter((key) => obj[key] !== undefined)
.sort();
return '{' + keys.map((key) => encodeString(key) + ':' + encode(obj[key])).join(',') + '}';
}
throw new Error(`cannot encode value of type ${type} in canonical JSON`);
}
/** JSON-encode a string and escape every non-ASCII char as \uXXXX, like Python. */
function encodeString(str: string): string {
return JSON.stringify(str).replace(/[€-￿]/g, (char) =>
'\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'),
);
}
// --- Ed25519 ---------------------------------------------------------------
/**
* Reconstruct a clean PEM from a value mangled by a credential field:
* surrounding whitespace, newlines escaped to literal `\n`, or common with
* single-line/password inputs the internal line breaks stripped entirely so
* the base64 body and the BEGIN/END markers run together.
*/
export function normalizePem(input: string): string {
let pem = (input || '').trim().replace(/\\n/g, '\n');
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+?)-----([\s\S]*?)-----END \1-----/);
if (match) {
const label = match[1].trim();
const body = (match[2].match(/[A-Za-z0-9+/=]+/g) || []).join('');
const wrapped = body.match(/.{1,64}/g) || [];
pem = `-----BEGIN ${label}-----\n${wrapped.join('\n')}\n-----END ${label}-----\n`;
}
return pem;
}
/**
* Load a PKCS8 PEM Ed25519 private key, tolerating the ways credential fields
* mangle multi-line secrets (see {@link normalizePem}).
*/
export function loadPrivateKey(privatePem: string): KeyObject {
const pem = normalizePem(privatePem);
try {
return createPrivateKey(pem);
} catch (err) {
throw new Error(
'Invalid Ed25519 private key: expected a PKCS8 PEM block ' +
'("-----BEGIN PRIVATE KEY-----"), e.g. the file from `browser-cli auth keygen`. ' +
`(${(err as Error).message})`,
);
}
}
/** Raw 32-byte Ed25519 public key (hex) derived from a PKCS8 PEM private key. */
export function ed25519PublicKeyHex(privatePem: string): string {
const publicKey = createPublicKey(loadPrivateKey(privatePem));
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
if (!jwk.x) throw new Error('private key is not an Ed25519 key');
return Buffer.from(jwk.x, 'base64url').toString('hex');
}
/** Bytes signed for auth: nonce + sha256(canonical) [+ sha256(label + secret)]. */
export function authMessage(nonceHex: string, msg: Record<string, unknown>, pqSecret: Buffer | null): Buffer {
const nonce = Buffer.from(nonceHex, 'hex');
const canonical = createHash('sha256').update(canonicalJson(stripAuthFields(msg)), 'utf8').digest();
let data = Buffer.concat([nonce, canonical]);
if (pqSecret) {
const bound = createHash('sha256')
.update(Buffer.concat([Buffer.from('browser-cli ml-kem-768 v1', 'ascii'), pqSecret]))
.digest();
data = Buffer.concat([data, bound]);
}
return data;
}
/** Ed25519 signature (hex) over the canonical auth payload. */
export function signAuth(
privatePem: string,
nonceHex: string,
msg: Record<string, unknown>,
pqSecret: Buffer | null,
): string {
return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex');
}
function stripAuthFields(msg: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(msg)) {
if (!AUTH_FIELDS.has(key)) out[key] = value;
}
return out;
}
// --- ML-KEM-768 transport encryption ---------------------------------------
// Node has no native ML-KEM, so load @noble/post-quantum at runtime. The
// indirection through `Function` keeps a real dynamic `import()` even after
// TypeScript downlevels this module to CommonJS (a plain `import()` would be
// rewritten to `require()` and fail on the ESM-only package).
const importEsm = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
let mlkemPromise: Promise<any> | null = null;
async function mlKem768(): Promise<any> {
if (!mlkemPromise) {
mlkemPromise = importEsm('@noble/post-quantum/ml-kem.js').then((mod) => mod.ml_kem768);
}
return mlkemPromise;
}
/** Encapsulate to the server's ML-KEM public key. Returns (ciphertext hex, secret). */
export async function pqEncapsulate(serverPublicKeyHex: string): Promise<{ ciphertextHex: string; secret: Buffer }> {
const ml = await mlKem768();
const { cipherText, sharedSecret } = ml.encapsulate(Buffer.from(serverPublicKeyHex, 'hex'));
return { ciphertextHex: Buffer.from(cipherText).toString('hex'), secret: Buffer.from(sharedSecret) };
}
/** HKDF-SHA256 with a 32-zero-byte salt (Python `salt=None`) and the given info. */
export function pqTransportKey(secret: Buffer, direction: string): Buffer {
const salt = Buffer.alloc(32, 0);
const prk = createHmac('sha256', salt).update(secret).digest();
const info = Buffer.concat([Buffer.from(`browser-cli pq transport v1 ${direction}`, 'ascii'), Buffer.from([1])]);
return createHmac('sha256', prk).update(info).digest().subarray(0, 32);
}
export interface PqEnvelope {
alg?: string;
nonce: string;
ciphertext: string;
}
/** ChaCha20-Poly1305 encrypt an app-layer frame (ciphertext field is ct||tag). */
export function pqEncrypt(secret: Buffer, direction: string, plaintext: Buffer): PqEnvelope {
const key = pqTransportKey(secret, direction);
const nonce = randomBytes(12);
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
alg: PQ_TRANSPORT_ALG,
nonce: nonce.toString('hex'),
ciphertext: Buffer.concat([body, tag]).toString('hex'),
};
}
/** Inverse of {@link pqEncrypt}. */
export function pqDecrypt(secret: Buffer, direction: string, envelope: PqEnvelope): Buffer {
if (!envelope || envelope.alg !== PQ_TRANSPORT_ALG) {
throw new Error('unsupported encrypted transport envelope');
}
const key = pqTransportKey(secret, direction);
const nonce = Buffer.from(envelope.nonce, 'hex');
const blob = Buffer.from(envelope.ciphertext, 'hex');
const tag = blob.subarray(blob.length - 16);
const body = blob.subarray(0, blob.length - 16);
const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]);
}
// --- Handshake payload + response decoding ---------------------------------
export interface Challenge {
type?: string;
nonce?: string;
min_client_version?: string;
pq_kex?: { alg?: string; public_key?: string };
}
export interface AuthPayload {
payload: Record<string, unknown>;
pqSecret: Buffer | null;
}
function pqPublicKey(challenge: Challenge): string | null {
const kex = challenge.pq_kex;
if (kex && kex.alg === PQ_KEX_ALG && kex.public_key) return String(kex.public_key);
return null;
}
/**
* Build the single framed message a client sends in response to the challenge.
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
*/
export async function buildAuthPayload(
baseMsg: Record<string, unknown>,
challenge: Challenge,
privatePem: string | null,
): Promise<AuthPayload> {
const nonceHex = challenge.type === 'challenge' ? challenge.nonce : undefined;
if (!nonceHex || !privatePem) {
// No-auth endpoint (loopback `serve --no-auth`): send the bare message.
return { payload: baseMsg, pqSecret: null };
}
const clean = stripAuthFields(baseMsg);
let secret: Buffer | null = null;
const serverPub = pqPublicKey(challenge);
if (serverPub) {
const enc = await pqEncapsulate(serverPub);
secret = enc.secret;
clean.pq_kex = { alg: PQ_KEX_ALG, ciphertext: enc.ciphertextHex };
}
const sig = signAuth(privatePem, nonceHex, clean, secret);
const pubkey = ed25519PublicKeyHex(privatePem);
if (!secret) {
return { payload: { ...clean, pubkey, sig }, pqSecret: null };
}
const encrypted = pqEncrypt(secret, 'request', Buffer.from(JSON.stringify(clean), 'utf8'));
return {
payload: {
id: clean.id,
user_agent: clean.user_agent,
pubkey,
sig,
pq_kex: clean.pq_kex,
encrypted,
},
pqSecret: secret,
};
}
/** Decode a framed server response, decrypting the PQ envelope when present. */
export function decodeResponse(raw: Buffer, pqSecret: Buffer | null): any {
const outer = JSON.parse(raw.toString('utf8'));
if (pqSecret && outer && typeof outer === 'object' && 'encrypted' in outer) {
return JSON.parse(pqDecrypt(pqSecret, 'response', outer.encrypted).toString('utf8'));
}
return outer;
}
@@ -0,0 +1,129 @@
/**
* Pure (resource, operation) -> browser-cli command mapping.
*
* This module imports nothing from n8n so it can be unit-tested with a plain
* esbuild/node toolchain. The node layer collects UI parameters into a plain
* object and asks here for the command + args to run over the `serve` socket
* (see `serveClient.ts`). Every operation maps to one raw extension command;
* what the server returns is the *raw* command result (no SDK-side rendering),
* still subject to the server's --allow-* policy noted per operation.
*/
export type CommandParams = Record<string, unknown>;
export interface BrowserCommand {
/** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */
command: string;
/** Argument object forwarded to the command. */
args: Record<string, unknown>;
}
function str(params: CommandParams, key: string): string {
const value = params[key];
return value === undefined || value === null ? '' : String(value);
}
/** Drop keys whose value is undefined/null/"" so we don't send empty args. */
function compact(args: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (value !== undefined && value !== null && value !== '') out[key] = value;
}
return out;
}
/**
* Map a (resource, operation) pair plus collected parameters to a single raw
* browser-cli command. Throws on an unknown pairing so the node fails loudly
* rather than silently issuing a wrong call.
*/
export function buildCommand(
resource: string,
operation: string,
params: CommandParams,
): BrowserCommand {
const key = `${resource}:${operation}`;
switch (key) {
// --- Raw escape hatch -------------------------------------------------
case 'command:execute':
return { command: str(params, 'command'), args: (params.args as Record<string, unknown>) ?? {} };
// --- Tabs -------------------------------------------------------------
case 'tab:list':
return { command: 'tabs.list', args: {} };
case 'tab:open': {
const focus = Boolean(params.focus);
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
}
case 'tab:close': {
const mode = str(params, 'mode') || 'ids';
if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } };
if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } };
return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } };
}
case 'tab:getHtml':
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
// --- Page / extraction ------------------------------------------------
case 'page:info':
return { command: 'page.info', args: {} };
case 'page:extractText':
return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractLinks':
return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractImages':
return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractHtml':
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
case 'page:extractMarkdown':
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
// --- DOM --------------------------------------------------------------
case 'dom:query':
return { command: 'dom.query', args: { selector: str(params, 'selector') } };
case 'dom:click':
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
case 'dom:type':
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
case 'dom:eval':
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
// --- Clients ----------------------------------------------------------
case 'client:list':
return { command: 'clients.list', args: {} };
// --- Gateway: serve has no health route, so ping with a safe command --
case 'gateway:health':
return { command: 'tabs.list', args: {} };
default:
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
}
}
/** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */
function tabIdArg(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
/** Accept an array, a JSON array string, or a comma/space separated list. */
export function parseTabIds(value: unknown): number[] {
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
if (typeof value === 'number') return [value];
const raw = String(value ?? '').trim();
if (!raw) return [];
if (raw.startsWith('[')) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite);
} catch {
/* fall through to split */
}
}
return raw
.split(/[\s,]+/)
.map((part) => Number(part))
.filter(Number.isFinite);
}
@@ -0,0 +1,147 @@
/**
* Socket client for a raw `browser-cli serve` endpoint.
*
* One command per connection: connect, read the challenge frame, send the
* authenticated (and PQ-encrypted) request frame, read the response frame,
* close. The crypto and payload shapes live in `protocol.ts`.
*/
import { connect as netConnect, isIP } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
import type { Socket } from 'node:net';
import { randomUUID } from 'node:crypto';
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
* client implements. */
const CLIENT_VERSION = '0.15.4';
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
const MAX_MSG_BYTES = 32 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 30_000;
export interface ServeConnectOptions {
host: string;
port: number;
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
tls?: boolean;
/** Reject self-signed / invalid certs when `tls` is on. */
rejectUnauthorized?: boolean;
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
privateKeyPem?: string | null;
/** Optional `_route` target for a multi-browser serve. */
route?: string | null;
timeoutMs?: number;
}
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
class FrameReader {
private buffer = Buffer.alloc(0);
private waiters: Array<(frame: Buffer) => void> = [];
private failed: Error | null = null;
constructor(socket: Socket) {
socket.on('data', (chunk: Buffer) => this.onData(chunk));
}
private onData(chunk: Buffer): void {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.buffer.length >= 4) {
const length = this.buffer.readUInt32LE(0);
if (length > MAX_MSG_BYTES) {
this.fail(new Error(`serve frame too large (${length} bytes)`));
return;
}
if (this.buffer.length < 4 + length) break;
const payload = this.buffer.subarray(4, 4 + length);
this.buffer = this.buffer.subarray(4 + length);
const waiter = this.waiters.shift();
if (waiter) waiter(Buffer.from(payload));
}
}
fail(error: Error): void {
this.failed = error;
}
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
next(): Promise<Buffer> {
if (this.failed) return Promise.reject(this.failed);
return new Promise((resolve) => this.waiters.push(resolve));
}
}
function openSocket(opts: ServeConnectOptions): Socket {
if (opts.tls) {
return tlsConnect({
host: opts.host,
port: opts.port,
rejectUnauthorized: opts.rejectUnauthorized !== false,
// SNI must be a hostname; Node rejects an IP literal as servername.
...(isIP(opts.host) ? {} : { servername: opts.host }),
});
}
return netConnect({ host: opts.host, port: opts.port });
}
/**
* Run a single browser-cli command against a `serve` endpoint and return the
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
*/
export async function sendServeCommand(
opts: ServeConnectOptions,
command: string,
args: Record<string, unknown>,
): Promise<any> {
const socket = openSocket(opts);
socket.setNoDelay(true);
const reader = new FrameReader(socket);
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise<any>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
fn();
};
const timer = setTimeout(
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
timeoutMs,
);
socket.on('error', (err) => finish(() => reject(err)));
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
const run = async () => {
const challengeRaw = await reader.next();
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
const baseMsg: Record<string, unknown> = {
id: randomUUID(),
command,
args: args ?? {},
user_agent: USER_AGENT,
accept_encoding: ACCEPT_ENCODING,
};
if (opts.route) baseMsg._route = opts.route;
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
const responseRaw = await reader.next();
const response = decodeResponse(responseRaw, pqSecret);
finish(() => resolve(response));
};
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
run().catch((err) => finish(() => reject(err)));
});
});
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "n8n-nodes-browser-cli",
"version": "0.2.4",
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
"keywords": [
"n8n-community-node-package",
"browser-cli",
"browser",
"automation",
"n8n"
],
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp",
"author": "Daniel Dolezal",
"scripts": {
"build": "tsc && node scripts/copy-assets.mjs",
"dev": "tsc --watch",
"test": "node scripts/run-tests.mjs",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/BrowserCliApi.credentials.js"
],
"nodes": [
"dist/nodes/BrowserCli/BrowserCli.node.js"
]
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.28.0",
"n8n-workflow": "*",
"typescript": "^5.6.0"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"dependencies": {
"@noble/post-quantum": "^0.6.1"
}
}
@@ -0,0 +1,17 @@
// Copy node icons next to their compiled .node.js files. n8n loads the icon
// from a path relative to the node file in dist/, so the SVG must travel along.
import { cp, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const assets = [
['nodes/BrowserCli/browserCli.svg', 'dist/nodes/BrowserCli/browserCli.svg'],
];
for (const [from, to] of assets) {
await mkdir(dirname(join(root, to)), { recursive: true });
await cp(join(root, from), join(root, to));
console.log(`copied ${from} -> ${to}`);
}
@@ -0,0 +1,27 @@
// Build the pure-logic tests to ESM and run them with node --test. request.ts
// and protocol.ts import nothing from n8n, so this needs only esbuild + node.
// node_modules stay external (--packages=external) so the @noble/post-quantum
// dynamic import in protocol.ts resolves at runtime instead of being bundled.
import { spawnSync } from 'node:child_process';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const run = (cmd, args) => spawnSync(cmd, args, { cwd: root, stdio: 'inherit' });
const suites = ['request', 'protocol'];
const build = run('npx', [
'esbuild',
...suites.map((name) => `test/${name}.test.ts`),
'--bundle',
'--format=esm',
'--platform=node',
'--packages=external',
'--outdir=test-dist',
'--out-extension:.js=.mjs',
]);
if (build.status !== 0) process.exit(build.status ?? 1);
const test = run('node', ['--test', ...suites.map((name) => `test-dist/${name}.test.mjs`)]);
process.exit(test.status ?? 1);
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
authMessage,
buildAuthPayload,
canonicalJson,
decodeResponse,
ed25519PublicKeyHex,
frame,
pqDecrypt,
pqEncrypt,
pqTransportKey,
signAuth,
} from '../nodes/BrowserCli/protocol';
// Known-answer vectors produced by the real Python implementation
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
// Regenerate with the snippet in the PR description if the protocol changes.
const PEM =
'-----BEGIN PRIVATE KEY-----\n' +
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
'-----END PRIVATE KEY-----\n';
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
const MSG = {
id: 'abc-123',
command: 'dom.type',
args: { selector: '#q', text: 'héllo ☃ "x"' },
user_agent: 'browser-cli/0.15.4',
accept_encoding: { ser: ['json'], comp: [] },
};
const CANON =
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
'"user_agent":"browser-cli/0.15.4"}';
const NONCE_HEX = '11'.repeat(32);
const SIG_NOPQ =
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
const SIG_PQ =
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
const DECRYPT_ENV = {
alg: 'ML-KEM-768+ChaCha20Poly1305',
nonce: 'a77e8a72e13f49aab713e0dc',
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
};
const DECRYPT_PLAIN = 'hello world payload';
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
assert.equal(canonicalJson(MSG), CANON);
});
test('canonicalJson sorts nested keys and omits undefined', () => {
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
});
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
});
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
});
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
// What a single-line/password credential field does to a multi-line PEM.
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
});
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
});
test('signAuth reproduces the Python signature without PQ', () => {
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
});
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
});
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
});
test('pqTransportKey matches Python HKDF for both directions', () => {
const secret = Buffer.from(SECRET_HEX, 'hex');
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
});
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
});
test('pqEncrypt/pqDecrypt round-trip', () => {
const secret = Buffer.from(SECRET_HEX, 'hex');
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
});
test('pqDecrypt rejects an unknown envelope', () => {
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
});
test('frame prepends a 4-byte little-endian length', () => {
const out = frame(Buffer.from('abc'));
assert.equal(out.readUInt32LE(0), 3);
assert.equal(out.subarray(4).toString(), 'abc');
});
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
assert.equal(pqSecret, null);
assert.equal((payload as any).pubkey, PUB_HEX);
assert.equal((payload as any).sig, SIG_NOPQ);
});
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
const base = { ...MSG };
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
assert.equal(pqSecret, null);
assert.equal(payload, base);
});
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
const secret = Buffer.from(SECRET_HEX, 'hex');
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCommand, parseTabIds } from '../nodes/BrowserCli/request';
test('tab:list maps to tabs.list', () => {
assert.deepEqual(buildCommand('tab', 'list', {}), { command: 'tabs.list', args: {} });
});
test('client:list maps to clients.list', () => {
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
});
test('gateway:health pings with tabs.list (serve has no health route)', () => {
assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.list', args: {} });
});
test('tab:open sends navigate.open with background derived from focus', () => {
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
assert.deepEqual(bg, {
command: 'navigate.open',
args: { url: 'https://example.com', focus: false, background: true },
});
const fg = buildCommand('tab', 'open', { url: 'https://example.com', focus: true });
assert.equal(fg.args.focus, true);
assert.equal(fg.args.background, false, 'focused open sends background:false (matches SDK)');
});
test('tab:close supports ids, inactive, duplicates', () => {
assert.deepEqual(buildCommand('tab', 'close', { mode: 'ids', tabIds: '12, 34' }), {
command: 'tabs.close',
args: { tabIds: [12, 34] },
});
assert.deepEqual(buildCommand('tab', 'close', { mode: 'inactive' }).args, { inactive: true });
assert.deepEqual(buildCommand('tab', 'close', { mode: 'duplicates' }).args, { duplicates: true });
});
test('dom:type sends dom.type with selector + text', () => {
assert.deepEqual(buildCommand('dom', 'type', { selector: '#q', text: 'hello' }), {
command: 'dom.type',
args: { selector: '#q', text: 'hello' },
});
});
test('dom:eval includes tabId only when set', () => {
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 0 }).args, { code: 'return 1' });
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 7 }).args, { code: 'return 1', tabId: 7 });
});
test('page:extractMarkdown omits empty selector', () => {
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: '' }).args, {});
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: 'main' }).args, { selector: 'main' });
});
test('command:execute passes command and args through', () => {
assert.deepEqual(buildCommand('command', 'execute', { command: 'tabs.query', args: { search: 'docs' } }), {
command: 'tabs.query',
args: { search: 'docs' },
});
});
test('unknown operation throws', () => {
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
});
test('parseTabIds accepts array, json, and delimited strings', () => {
assert.deepEqual(parseTabIds([1, 2, 3]), [1, 2, 3]);
assert.deepEqual(parseTabIds('[4, 5]'), [4, 5]);
assert.deepEqual(parseTabIds('6, 7 8'), [6, 7, 8]);
assert.deepEqual(parseTabIds(''), []);
assert.deepEqual(parseTabIds(9), [9]);
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2021"],
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"forceConsistentCasingInFileNames": true
},
"include": ["credentials/**/*.ts", "nodes/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}
+107 -107
View File
@@ -13,9 +13,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -30,9 +30,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -47,9 +47,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -64,9 +64,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -81,9 +81,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -98,9 +98,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -115,9 +115,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -132,9 +132,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -149,9 +149,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -166,9 +166,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -183,9 +183,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -200,9 +200,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -217,9 +217,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -234,9 +234,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -251,9 +251,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -268,9 +268,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -285,9 +285,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -302,9 +302,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -319,9 +319,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -336,9 +336,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -353,9 +353,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -370,9 +370,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -387,9 +387,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -404,9 +404,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -421,9 +421,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -438,9 +438,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -497,9 +497,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.0", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -510,32 +510,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0", "@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.0", "@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.0", "@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.0", "@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.0", "@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.0", "@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.0", "@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.0", "@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.0", "@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.0", "@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.0" "@esbuild/win32-x64": "0.28.1"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.0" version = "0.16.3"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
@@ -11,6 +11,7 @@ dependencies = [
"cryptography>=48", "cryptography>=48",
"rich>=13", "rich>=13",
"msgpack>=1", "msgpack>=1",
"questionary>=2",
] ]
[project.urls] [project.urls]
+4 -8
View File
@@ -461,8 +461,8 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation" assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path): def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it.""" """--key is a one-shot override; use `browser-cli remote trust` to remember it."""
import json as _json import json as _json
remotes_path = tmp_path / "remotes.json" remotes_path = tmp_path / "remotes.json"
@@ -491,16 +491,12 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote) monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
# First call with explicit --key agent
send_command("tabs.list", remote="host:8765", key=_Path("agent")) send_command("tabs.list", remote="host:8765", key=_Path("agent"))
assert used_keys[-1] == "agent" assert used_keys[-1] == "agent"
assert key_for_remote("host:8765") is None
# Key must be persisted now
assert key_for_remote("host:8765") == "agent"
# Second call without --key — should reuse saved "agent"
send_command("tabs.list", remote="host:8765") send_command("tabs.list", remote="host:8765")
assert used_keys[-1] == "agent" assert used_keys[-1] is None
# ── async command transport ────────────────────────────────────────────────── # ── async command transport ──────────────────────────────────────────────────
+50
View File
@@ -0,0 +1,50 @@
"""Security/robustness tests for the HTML→Markdown converter on hostile page content."""
from browser_cli.markdown.html import _MAX_TREE_DEPTH, convert_html_to_markdown
from browser_cli.markdown.render import render_markdown
def _identity(markdown):
return markdown
# ── depth-bounded recursion (Finding 4: HIGH/DoS) ─────────────────────────────────
def test_deeply_nested_html_does_not_crash():
"""Thousands of nested elements must not raise RecursionError."""
depth = 5000
html = "<div>" * depth + "deep content" + "</div>" * depth
out = convert_html_to_markdown(html, _identity)
assert "deep content" in out # text preserved despite flattening
def test_deeply_nested_via_render_markdown_entrypoint():
html = "<div>" * 3000 + "x" + "</div>" * 3000
out = render_markdown(html) # routes HTML through the converter
assert "x" in out
def test_nesting_within_cap_is_preserved_structurally():
# A modest list nesting (well under the cap) still renders as a list.
html = "<ul><li>a<ul><li>b</li></ul></li></ul>"
out = convert_html_to_markdown(html, _identity)
assert "- a" in out
assert "b" in out
def test_max_tree_depth_is_sane():
# Cap must be high enough for real documents, low enough to stay under the
# interpreter recursion limit with a few frames per level.
assert 50 <= _MAX_TREE_DEPTH <= 400
# ── unsafe URL schemes (Finding 5: LOW) ───────────────────────────────────────────
def test_javascript_url_in_link_is_neutralised():
# Anchors render their href only in inline context (inside a block like <p>).
out = convert_html_to_markdown('<p><a href="javascript:alert(1)">click</a></p>', _identity)
assert "javascript:" not in out
assert "click" in out # link text kept, dangerous href dropped
def test_data_and_vbscript_urls_dropped():
assert "vbscript:" not in convert_html_to_markdown('<p><a href="vbscript:x">y</a></p>', _identity)
assert "data:" not in convert_html_to_markdown('<img src="data:text/html,<script>">', _identity)
def test_normal_urls_pass_through():
out = convert_html_to_markdown('<p><a href="https://example.com">site</a></p>', _identity)
assert "(https://example.com)" in out
img = convert_html_to_markdown('<img src="https://example.com/x.png" alt="pic">', _identity)
assert "(https://example.com/x.png)" in img
+110
View File
@@ -237,9 +237,77 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)]) result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Policy" in result.output assert "Policy" in result.output
assert "Description" in result.output
assert "read-page" in result.output assert "read-page" in result.output
assert "all" in result.output assert "all" in result.output
assert "server default" in result.output assert "server default" in result.output
assert "read page content" in result.output
assert "Full access" in result.output
def test_auth_policy_updates_existing_key_policy(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "a" * 64
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
result = CliRunner().invoke(main, [
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
])
assert result.exit_code == 0
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
assert "Updated policy" in result.output
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
keys = tmp_path / "authorized_keys"
pub = "b" * 64
keys.write_text(f"{pub} laptop allow:all\n")
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
assert safe_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop allow:"
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
assert default_result.exit_code == 0
assert keys.read_text().strip() == f"{pub} laptop"
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
assert result.exit_code == 1
assert "Choose a policy mode" in result.output
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
keys = tmp_path / "authorized_keys"
keys.write_text(f"{'a' * 64} laptop\n")
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
assert result.exit_code == 1
assert "Choose exactly one policy mode" in result.output
def test_parse_interactive_policy_selection():
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
assert _parse_policy_selection("1,2") == ["read-page", "control"]
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
assert _parse_policy_selection("all") == ["all"]
assert _parse_policy_selection("safe") == []
assert _parse_policy_selection("default") is None
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
assert _parse_checkbox_policy_selection(["__safe__"]) == []
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
def test_auth_policy_without_identifier_requires_interactive_picker():
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
assert result.exit_code == 1
assert "Missing key identifier" in result.output
def test_auth_policy_remote_sends_policy_command():
pub = "c" * 64
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
assert result.exit_code == 0
send.assert_called_once()
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
assert "Updated policy" in result.output
def test_auth_keys_remote_unreachable_clean_error(): def test_auth_keys_remote_unreachable_clean_error():
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback.""" """`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
@@ -289,6 +357,48 @@ def test_serve_http_uses_compare_digest():
assert "compare_digest" in src assert "compare_digest" in src
assert "== f\"Bearer" not in src assert "== f\"Bearer" not in src
def test_serve_http_rate_limiter_blocks_when_exhausted():
"""A burst-1 limiter lets the first request through, then sends 429."""
from browser_cli.commands.serve_http import _Handler
from browser_cli.serve.security import RateLimiter
handler = _Handler.__new__(_Handler)
handler.client_address = ("203.0.113.5", 5000)
handler.rate_limiter = RateLimiter(rate=0.001, burst=1)
sent = []
handler._send = lambda status, payload: sent.append((status, payload))
assert handler._within_rate_limit() is True
assert handler._within_rate_limit() is False
assert sent and sent[-1][0] == 429
def test_serve_http_rate_limiter_none_never_limits():
from browser_cli.commands.serve_http import _Handler
handler = _Handler.__new__(_Handler)
handler.client_address = ("203.0.113.5", 5000)
handler.rate_limiter = None
assert all(handler._within_rate_limit() for _ in range(100))
def test_serve_http_default_rate_limit_active():
from browser_cli.commands.serve_http import _Handler
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
CliRunner().invoke(main, ["serve-http", "--no-auth"])
# The handler class passed to the server carries an active RateLimiter by default.
handler_cls = server_cls.call_args.args[1]
assert handler_cls.rate_limiter is not None
assert handler_cls.rate_limiter.rate == 100.0
def test_serve_http_non_loopback_warns_about_cleartext():
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--token", "x"])
assert "clear text" in result.output
def test_command_policy_allow_all_grants_everything(): def test_command_policy_allow_all_grants_everything():
policy = command_policy_from_options( policy = command_policy_from_options(
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
+42
View File
@@ -462,6 +462,48 @@ class TestPerKeyPolicy:
client.close() client.close()
t.join(timeout=2) t.join(timeout=2)
def _trust_and_query_keys(self, tmp_path, monkeypatch, server_policy):
"""Authenticate, then send browser-cli.auth.keys; return the response dict."""
from browser_cli.serve.security import ServeSecurity
path = tmp_path / "authorized_keys"
pem, pub = generate_keypair()
path.write_text(pub + " mykey\n")
key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem)
priv = load_private_key(key_path)
client, server = _pair()
t = _spawn(server, path, ServeSecurity(policy=server_policy))
challenge = _recv_framed(client)
nonce = bytes.fromhex(challenge["nonce"])
msg = {"id": "x", "command": "browser-cli.auth.keys", "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)
client.close()
t.join(timeout=2)
return resp
def test_key_management_blocked_without_keys_grant(self, tmp_path, monkeypatch):
"""Even full control+dangerous can't list keys — the control command is gated."""
from browser_cli.command_security import CommandPolicy
resp = self._trust_and_query_keys(
tmp_path, monkeypatch,
CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True),
)
assert resp["success"] is False
assert "blocked" in resp["error"].lower() and "keys" in resp["error"].lower()
def test_key_management_allowed_with_keys_grant(self, tmp_path, monkeypatch):
"""With allow_keys, the control command runs and returns the trusted-key list."""
from browser_cli.command_security import CommandPolicy
resp = self._trust_and_query_keys(tmp_path, monkeypatch, CommandPolicy(allow_keys=True))
assert resp["success"] is True
assert resp["data"][0]["name"] == "mykey"
class TestRateLimit: class TestRateLimit:
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch): def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
"""A burst-1 limiter shared across connections allows the first command, denies the next.""" """A burst-1 limiter shared across connections allows the first command, denies the next."""
+47 -1
View File
@@ -6,6 +6,7 @@ from browser_cli.auth.keys import (
format_authorized_line, format_authorized_line,
load_authorized_keys_with_names, load_authorized_keys_with_names,
load_authorized_keys_with_policies, load_authorized_keys_with_policies,
set_authorized_key_policy,
) )
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.serve.security import ( from browser_cli.serve.security import (
@@ -42,10 +43,11 @@ def test_key_commands_are_keys_category():
from browser_cli.command_security import command_category from browser_cli.command_security import command_category
assert command_category("browser-cli.auth.keys") == "keys" assert command_category("browser-cli.auth.keys") == "keys"
assert command_category("browser-cli.auth.trust") == "keys" assert command_category("browser-cli.auth.trust") == "keys"
assert command_category("browser-cli.auth.policy") == "keys"
assert command_category("browser-cli.targets") == "safe" # discovery stays open assert command_category("browser-cli.targets") == "safe" # discovery stays open
def test_key_commands_blocked_without_allow_keys(): def test_key_commands_blocked_without_allow_keys():
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"): for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed(cmd, CommandPolicy()) # safe-only default assert_command_allowed(cmd, CommandPolicy()) # safe-only default
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
@@ -57,6 +59,50 @@ def test_full_control_still_cannot_manage_keys():
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed("browser-cli.auth.trust", policy) assert_command_allowed("browser-cli.auth.trust", policy)
# ── set_authorized_key_policy ────────────────────────────────────────────────────
def test_set_policy_updates_by_pubkey(tmp_path):
path = tmp_path / "authorized_keys"
pub = "a" * 64
path.write_text(f"{pub} laptop\n")
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
def test_set_policy_by_name_and_remove_with_none(tmp_path):
path = tmp_path / "authorized_keys"
pub = "b" * 64
path.write_text(f"{pub} ci-bot allow:all\n")
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
def test_set_policy_safe_only_writes_empty_token(tmp_path):
path = tmp_path / "authorized_keys"
pub = "c" * 64
path.write_text(f"{pub} reader\n")
set_authorized_key_policy(path, pub, [])
assert path.read_text().strip() == f"{pub} reader allow:"
def test_set_policy_not_found_returns_none(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} laptop\n")
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
def test_set_policy_ambiguous_name_raises(tmp_path):
path = tmp_path / "authorized_keys"
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
with pytest.raises(ValueError, match="ambiguous"):
set_authorized_key_policy(path, "dup", ["control"])
def test_set_policy_preserves_other_lines(tmp_path):
path = tmp_path / "authorized_keys"
a, b = "a" * 64, "b" * 64
path.write_text(f"{a} first\n{b} second allow:read-page\n")
set_authorized_key_policy(path, a, ["control"])
assert load_authorized_keys_with_policies(path) == [
(a, "first", ["control"]),
(b, "second", ["read-page"]), # untouched
]
# ── authorized_keys line parsing ───────────────────────────────────────────────── # ── authorized_keys line parsing ─────────────────────────────────────────────────
def test_parse_line_pubkey_only(): def test_parse_line_pubkey_only():
Generated
+36 -1
View File
@@ -413,6 +413,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -463,14 +475,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.16.0" version = "0.16.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "msgpack" }, { name = "msgpack" },
{ name = "questionary" },
{ name = "rich" }, { name = "rich" },
] ]
@@ -491,6 +516,7 @@ requires-dist = [
{ name = "click", specifier = ">=8" }, { name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=48" }, { name = "cryptography", specifier = ">=48" },
{ name = "msgpack", specifier = ">=1" }, { name = "msgpack", specifier = ">=1" },
{ name = "questionary", specifier = ">=2" },
{ name = "rich", specifier = ">=13" }, { name = "rich", specifier = ">=13" },
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" }, { name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
] ]
@@ -579,6 +605,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "wcwidth"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
]
[[package]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"