Audit: control-plane exposure on macOS consolidated backend + WebSocket DLP gap (security/architecture/quality) #400
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Full-repo audit covering security, architecture, and general quality. Read at the tip of
main(dfc693e) withmacos-consolidated-gateway(#399,4a607ad) assumed merged, per the audit request — the shared consolidated gateway is treated as live on both the Firecracker and macOS backends.Findings are ranked by severity. The headline is a control-plane exposure that is specific to the macOS consolidated backend and defeats the product's core guarantees; everything else is lower-stakes.
🔴 Critical
1. The orchestrator control plane is unauthenticated and, on the macOS consolidated backend, directly reachable by every agent
Where:
bot_bottle/orchestrator/control_plane.py(no auth on any route),bot_bottle/backend/macos_container/infra.py(--host 0.0.0.0 --port 8099in the shared infra container),bot_bottle/backend/macos_container/gateway.py/launch.py(agent and infra container shareGATEWAY_NETWORK).The invariant that's broken.
registry.py::by_source_ipdocuments the precondition the whole design leans on: source-IP attribution is safe "only where the source IP is unspoofable and the control plane is reachable only by the trusted gateway." The control plane has no authentication of its own —dispatch()routes every request straight through; the only guard anywhere is the(source_ip, identity_token)check inside/resolveand/attribute. The mutating endpoints have no token check at all.Why it's exposed on macOS. On Firecracker the boundary holds: the nft table in
scripts/firecracker-netpool.shDNATs agent traffic to the infra VM only on the gateway ports{9099, 9100, 9420}and drops everything else, so port 8099 (control plane) is unreachable from a bottle VM. On the macOS backend the infra container runs both the control plane (8099) and the gateway data plane (9099/9100/9420) in one container (infra.py::_init_script), and the agent sits on the same--internalApple network (gateway.py::ensure_networks).container network create --internalgives no per-port filtering — containers on the same network reach each other on all ports. So every agent can reachhttp://<gateway_ip>:8099.What an agent can do once it reaches 8099. The agent already knows its own identity token — it's handed to the agent process in
HTTPS_PROXY=http://bottle:<token>@<gw>:9099(launch.py::_identity_proxy_env) — and its own source IP from its NIC. From inside the bottle:GET /bottles→ enumerate every sibling bottle on the host, including each one'spolicyandmetadata(BottleRecord.redacted()omits only the token). Cross-bottle disclosure.PUT /bottles/<id>/policy→ rewrite its own egress allowlist to allow-all (no token required, just a bottle_id from the list above). The gateway serves the new routes on the next/resolve. This defeats the per-bottle egress allowlist — the product's primary control.POST /resolvewith its own(source_ip, identity_token)→ the response includestokens, the real upstream credentials the gateway injects on the bottle's behalf (control_plane.py:188,orch.tokens_for). This directly defeats the README's "Tokens the agent never sees" guarantee — the agent reads them straight out.POST /supervise/respond→ self-approve its own pending supervise proposals (egress-allow, token-allow). The human approval gate becomes a formality the agent can click through for itself.POST /bottles/DELETE /bottles/<id>→ launch or tear down arbitrary bottles on the host, affecting other agents.Net: on the macOS consolidated backend a prompt-injected or misbehaving agent can lift the egress allowlist, read the credentials it was never supposed to see, and rubber-stamp its own approvals — i.e. every guarantee in the README's feature list collapses to the agent's own discretion.
Fix direction (any one closes it; defense-in-depth wants more than one):
/bottles*,/supervise/respond, and thetokensfield of/resolve./resolveand/attributewould still additionally require the caller's(source_ip, identity_token).containernetworking may not support this cleanly, which is exactly why the auth fix is the durable one.Also verify: the docker consolidated backend. If its control-plane container sits on the agent's
--internalnetwork the same exposure applies there; only Firecracker's port-scoped nft demonstrably prevents it today.🟠 High
2. WebSocket DLP is silently inert in the consolidated (multi-tenant) gateway
Where:
bot_bottle/egress_addon.py::websocket_message(~L623–658).Unlike
request()/response(), which resolve the calling bottle's config per request via_resolve_flow,websocket_messagescans againstself.configandself._supervise_slug. In consolidated mode no per-bottleroutes.yamlis written (the egress addon runs purely offBOT_BOTTLE_ORCHESTRATOR_URL), soself.configstaysConfig(routes=()).match_routetherefore returnsNoneand the handler returns without scanning or killing the frame. The code comment concedes this ("inert until websocket routing is made source-IP-aware").Impact: for any host that made it past the CONNECT allowlist, WebSocket frames flow with no outbound credential DLP and no inbound prompt-injection DLP on the shared gateway — a live exfil channel for streaming agents. It fails open, and silently, which is the wrong direction for a DLP control. At minimum it should fail closed (kill WS frames when the addon can't resolve a per-flow config) until per-flow WS resolution lands.
🟡 Medium — architecture / maintainability
3. Two live tenancy code paths (single-tenant vs consolidated) across every gateway-facing module
egress_addon.py,git_http_backend.py,policy_resolver.py, and the supervise plumbing each carry a "legacy single-tenant" branch alongside the consolidated one, described as transitional. Every branch is security-relevant and has to be kept correct in parallel — finding #2 is exactly a case where one path (WS) never got the consolidated treatment. Recommend tracking the single-tenant removal as a real deprecation with a target, not an open-ended "until every backend runs consolidated."4. Flat-file import-shim duplication for gateway-bundled modules
egress_addon_core,dlp_detectors,policy_resolver,git_http_backend,yaml_subset,egress_dlp_configare COPYed flat into the gateway image and every one uses atry: import X / except ImportError: from .Xshim, plus hand-maintained duplicate constants (e.g.IDENTITY_HEADER,GIT_GATE_TIMEOUT_SECScopied rather than imported). A rename or a missed file inDockerfile.gatewayfails only at runtime inside the container, not in host tests or pyright. Worth a packaging step (or a single bundled subpackage) that makes the gateway bundle a build artifact of the real package rather than a parallel flat copy.5. Custom YAML subset parser is a security-relevant parse surface
yaml_subset.py(685 lines) is a hand-rolled parser sitting on the manifest + egress-policy trust boundary. It's well unit-tested (597-line test file), but a bespoke parser in this position warrants fuzzing (malformed/adversarial policy blobs, deeply nested input, resource-exhaustion cases) rather than example-based tests alone — a parser bug here is a policy-bypass or DoS.Positives worth recording
secrets.token_urlsafetokens,hmac.compare_digestcomparison, both-signals-required, fail-closed on ambiguity (registry.py::attribute)._closest_pairO(n log n) rewrite explicitly to kill a quadratic DoS on attacker-controlled bodies.resolve_client_config,resolve_sandbox_root, and the gateway resolvers — unattributed/errored/unparseable all deny.Filed from an automated audit. The Critical is the actionable one and is contained to the macOS consolidated backend as written — Firecracker's port-scoped nft blocks the same attack today.
Fixing the 🔴 Critical (control-plane exposure) in branch
macos-consolidated-gateway(#399).Approach: fix direction #1 — authenticate the control plane — since it's the backend-agnostic, durable fix and closes the exposure on both macOS and docker (docker's orchestrator container is on the shared
GATEWAY_NETWORKtoo, so the same attack applies there, as the audit flagged).A per-host control-plane secret (
~/.bot-bottle/control-plane-token, 0600, minted once) is required on every route exceptGET /health, checked withhmac.compare_digest. It's held only by the trusted callers, never on the agent network:PolicyResolver) sends it on/resolve+/attribute(same env, read in-container);OrchestratorClient) send it, read from the host file.The agent never receives the env var or the host file, so from a bottle every
/bottles*,/resolve,/attribute, and/supervise/*call gets401— closing the allowlist-rewrite, thetokens(upstream-credential) read, and the supervise self-approval. The existing(source_ip, identity_token)check on/resolve+/attributestays as defense-in-depth.Enforced when configured: macOS + docker inject the token (→ enforced, vuln closed). Firecracker's port-scoped nft already blocks 8099 from agent VMs, so it stays protected today; wiring the same secret into its infra-VM init is a clean fast-follow for defense-in-depth (left out here to avoid churning the prebuilt-artifact hash).
The other findings (WS DLP fail-open #2, tenancy-path/duplication/parser items #3–5) are out of scope for this fix and remain open.
Fixed in
ca91fc4onmacos-consolidated-gateway(#399).The control plane now requires the per-host secret on every route but
GET /health(hmac-compared). Verified end-to-end on real Apple Container, running the exact attacks from the report inside a live agent (which holds its own identity token but not the admin secret):GET /bottles(enumerate siblings)POST /resolve(lift upstream creds)tokensPUT /bottles/<id>/policy(allow-all)POST /supervise/respond(self-approve)DELETE /bottles/<id>(nuke a bottle)And the legitimate paths still work: the host CLI (holds the secret) lists bottles fine while an unauthenticated request gets 401, and egress policy still enforces (200 allowed / 403 denied) — which proves the gateway authenticates to
/resolvewith the injected secret rather than being locked out itself.Scope notes:
1829 unit tests pass (incl. new control-plane auth tests), pyright clean, pylint 9.91.