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:
@@ -29,11 +29,29 @@ the gateway.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
# The control-plane secret this gateway presents on every /resolve call, read
|
||||
# from the env the launcher injects into the gateway container. The control
|
||||
# plane requires it (orchestrator/control_plane.py). Constant + env-var name are
|
||||
# duplicated here rather than imported because this module is COPYed flat into
|
||||
# the gateway image, free of bot-bottle imports — same rationale as
|
||||
# IDENTITY_HEADER in egress_addon / git_http_backend.
|
||||
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
|
||||
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
||||
|
||||
|
||||
def _control_auth_headers() -> dict[str, str]:
|
||||
"""The auth header to send, or {} when no secret is configured (an open
|
||||
control plane, e.g. Firecracker behind its nft boundary — sending nothing
|
||||
is correct there and harmlessly ignored)."""
|
||||
token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
|
||||
return {CONTROL_AUTH_HEADER: token} if token else {}
|
||||
|
||||
|
||||
class PolicyResolveError(RuntimeError):
|
||||
"""The orchestrator was unreachable or returned an unexpected status —
|
||||
@@ -57,7 +75,7 @@ class PolicyResolver:
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._base}/resolve", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={"Content-Type": "application/json", **_control_auth_headers()},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
|
||||
Reference in New Issue
Block a user