ADR-039: Web Control Plane Authentication and Loopback Exposure¶
Status¶
accepted
Date¶
2026-06-14
Context¶
The optional web profile adds an operator-facing FastAPI control plane and terminal relay on top of the same lab lifecycle code used by the CLI. That API can start, stop, inspect, and kill the lab, and the API container intentionally mounts the host Docker socket so it can drive Docker Compose. This is not a deliberately vulnerable target service; it is a control plane with host-equivalent Docker reach.
Existing boundaries already own parts of the design:
src/aptl/api/main.pyowns FastAPI application assembly, CORS, mounted routers, and/api/health.src/aptl/api/deps.pyowns API-wide dependencies and environment-derived API constants such as allowed origins and project directory resolution.src/aptl/api/schemas.pyowns Pydantic response shapes projected from core models; web types mirror those wire shapes.src/aptl/core/config.pyand ADR-025 own durable, non-secret first-party configuration inaptl.json.src/aptl/core/env.py,src/aptl/utils/placeholders.py, and theServiceConfig.from_env()pattern insrc/aptl/services/misp_suricata_sync/config.pyown parse-then-validate environment binding for runtime service settings.src/aptl/utils/redaction.pyand ADR-029 own control-plane secret handling before values cross logs, API responses, CLI output, telemetry, or persisted artifacts.src/aptl/core/deployment/and ADR-023/ADR-037 own Docker and Docker Compose access through typed backend methods, not generic command passthroughs.
CORS and the existing WebSocket Origin allow-list are browser controls. They
do not authenticate non-browser clients, local processes, LAN peers, or forged
WebSocket handshakes.
Decision¶
The web control plane must be protected by two independent defaults:
- Loopback exposure by default. The API and UI host publishes must bind to
127.0.0.1by default in Docker Compose, and host-run API serving must keep127.0.0.1as its default bind address. Any non-loopback exposure is an explicit operator risk decision, not the default profile behavior. - Authentication on the whole API surface. Every HTTP request under
/api, including/api/health, SSE endpoints, and state-changing routes, requires the web control-plane token before route logic runs. The terminal WebSocket requires the same token beforeaccept(). Unknown/api/*paths should not become an unauthenticated route-enumeration side channel; an unauthenticated caller should see the auth failure before any route-specific result.
The token is a control-plane/operator secret under ADR-029. It belongs in
runtime environment binding, not in aptl.json, checked-in config, generated
client bundles, URL examples, logs, or command-line arguments. The auth settings
shape should follow the existing strict env-to-Pydantic pattern used by
ServiceConfig.from_env(): parse once, validate once, reject missing or
placeholder-like values with name-attributed errors, and expose a narrow typed
object to the API layer.
HTTP and WebSocket enforcement must share one canonical auth helper in the API
boundary, preferably aptl.api.deps. That helper owns token extraction,
constant-time comparison, and safe error construction. Do not copy bearer
parsing into individual routers, and do not create a second exception
hierarchy or response schema for auth failures. HTTP failures use FastAPI's
existing 401 error envelope with a narrow WWW-Authenticate: Bearer header;
WebSocket failures close with policy violation before the SSH relay is opened.
Bearer authorization headers are the primary HTTP carrier. Browser constraints must not weaken the design:
- Native
EventSourcecannot set anAuthorizationheader, so SSE must use a header-capable client path such as fetch streaming, a small EventSource replacement, or a same-origin server-side proxy. Do not put the token in the event-stream URL. - Browser WebSocket constructors cannot set arbitrary headers, so the terminal
path needs a non-URL carrier such as an authenticated same-origin proxy or a
validated
Sec-WebSocket-Protocoltoken convention.Originremains a CSWSH defense only, not an auth credential. - If a future design uses cookies instead of bearer headers, it must add an
explicit CSRF design and revisit CORS credentials. Do not silently flip
allow_credentials=True.
CORS remains an allow-list convenience for browser calls. It is not an
authorization layer. If bearer headers are used, Authorization is an allowed
header; wildcard origins remain out of scope for the operator control plane.
The Docker socket mount remains high risk even after auth and loopback binding.
This ADR does not make raw Docker control safe for arbitrary API expansion.
Future socket reduction should use a least-privilege socket proxy or typed
DeploymentBackend operations. Do not add generic "run docker args" endpoints.
Security Layers¶
| Layer | Requirement |
|---|---|
| Auth surface | A single API auth boundary covers HTTP /api/*, /api/health, SSE, and the terminal WebSocket before router/core logic runs. |
| Secret handling | The token is an ADR-029 control-plane secret. Logs, exceptions, API error text, telemetry, snapshots, and docs examples must never include its value. Use redact() before any auth-bearing text crosses a boundary. |
| Env binding | Token settings are runtime environment settings validated through one strict parser. Reuse contains_placeholder() semantics; do not store the token in AptlConfig or overload EnvVars with unrelated web auth state. |
| OS/process exposure | Pass the token through environment or server-side state, not process argv, URL query strings, command strings, or access-log-visible paths. |
| Network exposure | Docker Compose port publishes and host-run serving default to loopback. Non-loopback exposure is explicit and documented. |
| Browser carrier | REST and SSE keep token-bearing auth out of URLs; WebSocket auth uses a non-URL carrier or authenticated proxy. Origin and CORS remain browser defenses only. |
| Error envelope | HTTP returns a narrow 401 envelope; WebSocket closes with policy violation. Neither path reports whether the token was missing, malformed, or wrong beyond the generic auth failure. |
| Docker boundary | API routes still delegate lab work to existing core services and DeploymentBackend; auth does not justify new raw Docker/socket passthroughs. |
Extensibility¶
The extensibility seam is a small, typed web-auth settings and carrier parser at the API boundary. Future changes such as token rotation, a socket proxy, a same-origin UI proxy, or a different credential carrier should replace that boundary without editing every router.
The network-exposure seam is the bind host in deployment and serving
configuration, with 127.0.0.1 as the default. A future documented remote UI
mode may parameterize that value, but it must not scatter 0.0.0.0 literals
across Dockerfiles, Compose services, tests, and docs.
Non-Goals¶
- Do not implement multi-user accounts, RBAC, OAuth/OIDC, sessions, password login, or long-lived browser identity.
- Do not redesign the SvelteKit application architecture or resurrect removed scenario API routes as part of auth hardening.
- Do not remove the Docker socket mount in this issue; treat a socket proxy as a follow-up hardening track.
- Do not move web auth secrets into
aptl.json, checked-in config, or generated source-owned files. - Do not redesign
DeploymentBackend, lab lifecycle result DTOs, API response schemas, or redaction policy.
Anti-Patterns¶
- Treating CORS, allowed origins, loopback binding, or "local lab" comments as authentication.
- Adding per-router auth snippets instead of one canonical API auth boundary.
- Leaving
/api/health, SSE, WebSocket, or unknown/api/*paths outside the auth check. - Passing tokens in query strings, WebSocket URLs, curl examples, process argv, logs, exception details, OpenAPI examples, or access-log-visible paths.
- Hardcoding a default token, accepting empty tokens, or allowing
.env.exampleplaceholder values to start the control plane. - Reusing the Wazuh-oriented
EnvVarsdataclass as a generic web settings bag. - Creating new API DTOs, exception hierarchies, validation helpers, or Docker command wrappers when existing FastAPI, Pydantic, redaction, and deployment boundaries already cover the need.