Both tables grew one entry per distinct key and never shrank, so a long-running SDK client or a public serve process accumulated state for every endpoint or identity it had ever seen. GC cannot reclaim them while the pool and limiter still reference them. Cap the pool at 64 endpoint buckets and the limiter at 4096 buckets. Both evict useless state first: connections past the idle timeout the server has likely dropped anyway, and buckets that have fully refilled, which carry no throttling information. Only then fall back to evicting the oldest entry. Evicting a limiter bucket resets throttling for that identity, which is the deliberate trade: an attacker cycling identities faster than they go idle can regain tokens, but unbounded growth would take the process down instead.
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""Server-side authorization, per-key policy and rate limiting for ``browser-cli serve``.
|
|
|
|
This bundles the three serve-time security concerns that travel together through
|
|
the connection-handling chain:
|
|
|
|
- ``policy`` the server-wide default ``CommandPolicy`` (from ``--allow-*``)
|
|
- ``key_policies`` optional per-pubkey overrides parsed from the ``allow:`` token
|
|
in the ``authorized_keys`` file
|
|
- ``key_names`` pubkey -> friendly name (from authorized_keys), for audit logs
|
|
- ``rate_limiter`` optional per-identity token-bucket throttle
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from browser_cli.command_security import CommandPolicy
|
|
|
|
# ── per-key authorization ───────────────────────────────────────────────────────
|
|
|
|
_CATEGORY_FLAGS = {
|
|
"read-page": "allow_read_page",
|
|
"control": "allow_control",
|
|
"dangerous": "allow_dangerous",
|
|
"keys": "allow_keys",
|
|
}
|
|
|
|
def policy_from_categories(categories) -> CommandPolicy:
|
|
"""Build a CommandPolicy from category strings (``all``/``safe``/``read-page``/``control``/``dangerous``)."""
|
|
cats = [str(c).strip().lower() for c in categories]
|
|
if "all" in cats:
|
|
return CommandPolicy.unrestricted()
|
|
kwargs: dict[str, bool] = {}
|
|
for cat in cats:
|
|
if cat in ("", "safe"):
|
|
continue
|
|
flag = _CATEGORY_FLAGS.get(cat)
|
|
if flag is None:
|
|
raise ValueError(
|
|
f"unknown command category {cat!r}; expected one of: all, safe, read-page, control, dangerous"
|
|
)
|
|
kwargs[flag] = True
|
|
return CommandPolicy(**kwargs)
|
|
|
|
def key_policies_from_authorized_keys(path: Path | str | None) -> dict[str, CommandPolicy]:
|
|
"""Build ``{pubkey: CommandPolicy}`` from the ``allow:`` tokens in authorized_keys.
|
|
|
|
Only keys that carry an explicit ``allow:`` token get an entry; keys without
|
|
one fall back to the server-wide default policy. Pubkeys are normalised to
|
|
lowercase hex. Raises ``ValueError`` on an unknown category so the server fails
|
|
loudly at startup rather than silently mis-gating.
|
|
"""
|
|
if path is None:
|
|
return {}
|
|
from browser_cli.auth import load_authorized_keys_with_policies
|
|
|
|
out: dict[str, CommandPolicy] = {}
|
|
for pubkey, _name, categories in load_authorized_keys_with_policies(Path(path)):
|
|
if categories is not None:
|
|
out[pubkey.strip().lower()] = policy_from_categories(categories)
|
|
return out
|
|
|
|
# ── per-identity rate limiting ───────────────────────────────────────────────────
|
|
|
|
class RateLimiter:
|
|
"""Token bucket keyed by identity (pubkey, or client address when unauthenticated).
|
|
|
|
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
|
|
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
|
|
Thread-safe so it can be shared across all connections of one serve process.
|
|
|
|
The bucket table is capped. Without that bound, a long-running public server
|
|
could retain one entry per ever-seen identity/IP forever; GC cannot reclaim
|
|
those entries because the limiter still references them.
|
|
"""
|
|
|
|
def __init__(self, rate: float, burst: float | None = None, max_buckets: int = 4096) -> None:
|
|
self.rate = float(rate)
|
|
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
|
|
self.max_buckets = max(1, int(max_buckets))
|
|
self._buckets: dict[str, tuple[float, float]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def _prune_locked(self, now: float) -> None:
|
|
"""Drop idle/full buckets, then oldest buckets, until the table is bounded."""
|
|
if len(self._buckets) < self.max_buckets or self.rate <= 0:
|
|
return
|
|
|
|
# Once a bucket has fully refilled, keeping it around carries no useful
|
|
# throttling state. Use at least 60s so normal active identities are not
|
|
# churned out aggressively on high-rate configs.
|
|
idle_seconds = max(60.0, (self.capacity / self.rate) * 2)
|
|
full_epsilon = 1e-9
|
|
for bucket_key, (tokens, last) in list(self._buckets.items()):
|
|
refilled = min(self.capacity, tokens + (now - last) * self.rate)
|
|
if refilled >= self.capacity - full_epsilon and now - last >= idle_seconds:
|
|
self._buckets.pop(bucket_key, None)
|
|
|
|
# If an attacker keeps creating fresh identities faster than they go idle,
|
|
# still keep memory bounded. Evict the oldest identity state; that may reset
|
|
# throttling for that identity, but bounded memory is more important here.
|
|
while len(self._buckets) >= self.max_buckets:
|
|
oldest_key = min(self._buckets, key=lambda k: self._buckets[k][1])
|
|
self._buckets.pop(oldest_key, None)
|
|
|
|
def allow(self, key: str) -> bool:
|
|
if self.rate <= 0:
|
|
return True
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
if key not in self._buckets and len(self._buckets) >= self.max_buckets:
|
|
self._prune_locked(now)
|
|
tokens, last = self._buckets.get(key, (self.capacity, now))
|
|
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
|
if tokens < 1.0:
|
|
self._buckets[key] = (tokens, now)
|
|
return False
|
|
self._buckets[key] = (tokens - 1.0, now)
|
|
return True
|
|
|
|
# ── bundled server security context ──────────────────────────────────────────────
|
|
|
|
@dataclass(frozen=True)
|
|
class ServeSecurity:
|
|
policy: CommandPolicy = field(default_factory=CommandPolicy.unrestricted)
|
|
key_policies: dict[str, CommandPolicy] = field(default_factory=dict)
|
|
key_names: dict[str, str] = field(default_factory=dict)
|
|
rate_limiter: RateLimiter | None = None
|
|
|
|
def effective_policy(self, pubkey: str | None) -> CommandPolicy:
|
|
"""Per-key override if one exists for this pubkey, else the server default."""
|
|
if pubkey and pubkey in self.key_policies:
|
|
return self.key_policies[pubkey]
|
|
return self.policy
|
|
|
|
def label_for(self, pubkey: str | None) -> str | None:
|
|
"""Audit label for log lines: ``<name> <short-pubkey>…`` or just the short pubkey."""
|
|
if not pubkey:
|
|
return None
|
|
short = f"{pubkey[:8]}…"
|
|
name = self.key_names.get(pubkey, "")
|
|
return f"{name} {short}".strip() if name else short
|