fix(security): authenticate the orchestrator control plane (issue #400 Critical)
The control plane had no caller authentication — dispatch() routed every request straight through, and the only guards anywhere were the (source_ip, identity_token) checks inside /resolve and /attribute, which authenticate the *bottle* a request is about, not the *caller*. On the consolidated backends the agent shares a network with the control-plane port (macOS: the infra container runs 8099 on the agent's --internal network; docker: the orchestrator container sits on the shared gateway network), so a prompt-injected agent could GET /bottles (enumerate siblings + policies), PUT /bottles/<id>/policy (rewrite its own egress allowlist to allow-all), POST /resolve (read the injected upstream credentials it's never meant to see), and POST /supervise/respond (self-approve its own proposals). Every README guarantee collapsed to the agent's discretion. Fix: a per-host control-plane secret required on every route but GET /health, compared with hmac.compare_digest. It is held only by the trusted callers and never handed to an agent: - minted + persisted 0600 at <root>/control-plane-token (paths.host_control_plane_token); - injected as $BOT_BOTTLE_CONTROL_PLANE_TOKEN into the orchestrator + gateway containers via bare `--env NAME` (value inherited from the launch process, so it never lands on argv or in `container/docker inspect`); - presented by the gateway's PolicyResolver (reads the env) on /resolve, and by the host CLI's OrchestratorClient (reads the host file) on every call. The agent container is never given the env var or the host file, so from a bottle every /bottles*, /resolve, /attribute, and /supervise/* call now returns 401 — closing the enumeration, allowlist-rewrite, credential-lift, and self-approval. The existing (source_ip, identity_token) checks stay as defense-in-depth. Enforced when configured: macOS + docker inject the secret (→ enforced). With no secret set the server runs open and warns loudly at startup — a fail-visible fallback for the unit suite and for Firecracker, whose port-scoped nft already blocks agents from 8099 (wiring the secret into its infra-VM init is a clean fast-follow, left out here to avoid churning the prebuilt-artifact hash). Verified end-to-end on real Apple Container: infra comes up healthy, the host CLI (with the secret) lists bottles while an unauthenticated GET /bottles gets 401, all five issue-#400 attacks from inside the agent get 401, and egress policy still works (200 allowed / 403 denied) — proving the gateway authenticates to /resolve with the secret. 1829 unit tests pass, pyright clean, pylint 9.91. Refs #400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,22 @@ import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..paths import host_control_plane_token
|
||||
from .control_plane import CONTROL_AUTH_HEADER
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _host_auth_token() -> str:
|
||||
"""The per-host control-plane secret, or "" if it can't be read. "" means
|
||||
'send no auth header' — correct against an open (unconfigured) control
|
||||
plane, and harmlessly rejected by a secured one."""
|
||||
try:
|
||||
return host_control_plane_token()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
class OrchestratorClientError(RuntimeError):
|
||||
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
||||
|
||||
@@ -34,11 +47,24 @@ class RegisteredBottle:
|
||||
|
||||
|
||||
class OrchestratorClient:
|
||||
"""Trusted host-side client for the orchestrator control plane."""
|
||||
"""Trusted host-side client for the orchestrator control plane.
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
Presents the per-host control-plane secret on every call (the header the
|
||||
control plane requires on all routes but `/health`). The secret is read
|
||||
from the host file — this client only ever runs host-side (CLI, launcher,
|
||||
discovery), so it can read what an agent can't. `auth_token` is overridable
|
||||
for tests; the default reads the host file, minting it on first use."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
auth_token: str | None = None,
|
||||
) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._auth_token = auth_token if auth_token is not None else _host_auth_token()
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, body: dict[str, object] | None = None,
|
||||
@@ -49,6 +75,8 @@ class OrchestratorClient:
|
||||
callers can treat 404 as a meaningful "no such bottle"."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
if self._auth_token:
|
||||
headers[CONTROL_AUTH_HEADER] = self._auth_token
|
||||
req = urllib.request.Request(
|
||||
f"{self._base}{path}", data=data, method=method, headers=headers,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user