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.
This commit is contained in:
@@ -11,9 +11,13 @@ from rich.console import Console
|
||||
from browser_cli import BrowserCLI
|
||||
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.serve.security import RateLimiter
|
||||
|
||||
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:
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
@@ -21,6 +25,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
client: BrowserCLI
|
||||
token: str | None = None
|
||||
policy: CommandPolicy = CommandPolicy()
|
||||
rate_limiter: RateLimiter | None = None
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
if self.token is None:
|
||||
@@ -37,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send(401, {"error": "missing or invalid token"})
|
||||
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):
|
||||
raw = json.dumps(payload, default=str).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -48,8 +59,11 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path != "/health" and not self._require_auth():
|
||||
return
|
||||
if path != "/health":
|
||||
if not self._require_auth():
|
||||
return
|
||||
if not self._within_rate_limit():
|
||||
return
|
||||
if path == "/tabs":
|
||||
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
|
||||
elif path == "/clients":
|
||||
@@ -64,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path == "/command":
|
||||
if not self._require_auth():
|
||||
return
|
||||
command = body.get("command")
|
||||
assert_command_allowed(command, self.policy)
|
||||
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
|
||||
else:
|
||||
if path != "/command":
|
||||
self._send(404, {"error": "not found"})
|
||||
return
|
||||
if not self._require_auth():
|
||||
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")
|
||||
assert_command_allowed(command, self.policy)
|
||||
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
|
||||
except PermissionError as exc:
|
||||
self._send(403, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
@@ -90,21 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
@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("--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
|
||||
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).
|
||||
|
||||
Auth is enabled by default. Pass the printed token as either
|
||||
``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):
|
||||
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))
|
||||
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(
|
||||
"BrowserCLIHTTPHandler",
|
||||
(_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)
|
||||
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
|
||||
|
||||
Reference in New Issue
Block a user