ADR-0967: MCP HTTP transport security — add auth + body limit + safer bind default (Round 26 audit A.1)¶
- Status: Accepted
- Date: 2026-05-31
- Deciders: Lusoris
- Tags: security, mcp, http, auth, hardening, fork-local
Context¶
The MCP HTTP transport (mcp-server/vmaf-mcp/src/vmaf_mcp/http_transport.py, introduced in ADR-0701) shipped three gaps that were flagged as finding A.1 in the Round 26 security audit:
-
Unbounded request bodies.
_handle_scorecalledawait request.json()with no upper bound on the request body. A client could stream an arbitrarily large POST and exhaust server memory or trigger an OOM kill. -
No authentication. Any client on the network that could reach the TCP port received a valid response. There was no
Authorizationheader enforcement, no token, no opt-in gate. -
Default bind
0.0.0.0. The server bound all interfaces unconditionally. In a container or VM without a network firewall, this exposed the MCP endpoint to the entire host network — including any bridge networks shared with other containers.
Breaking-change note (bind default): Pre-ADR-0967 the default bind host was 0.0.0.0. After ADR-0967 it is 127.0.0.1. Existing Helm deployments, Docker Compose files, and bare docker run invocations that rely on pod-network or container-network reachability must set VMAFX_MCP_HTTP_BIND=0.0.0.0 to restore the old behaviour. The Helm values file (deploy/helm/vmafx/values.yaml) documents this operator choice.
Decision¶
We add three complementary security layers to the HTTP transport, all configurable via environment variables (12-factor §III) and implemented without new runtime dependencies:
- Body size limit (4 MiB). Two complementary mechanisms enforce it:
- A
Content-Lengthpre-flight in the security middleware rejects requests whose declared size exceedsMAX_REQUEST_BODY_BYTES(4 MiB) immediately with HTTP 413, before any body bytes are read. -
client_max_size=MAX_REQUEST_BODY_BYTESon the aiohttpApplicationenforces the limit for chunked / streaming bodies — aiohttp raisesHTTPRequestEntityTooLarge(413) insiderequest.read()/request.json(). -
Bearer token authentication. A new
@web.middlewarefunction checksAuthorization: Bearer <token>on every request: - Token is read from
VMAFX_MCP_HTTP_TOKEN. If the env var is unset andVMAFX_MCP_HTTP_NO_AUTHis also unset, the server starts but rejects every request with HTTP 401. This "fail-closed" default means an operator who forgets to configure auth sees a clear 401 rather than an accidentally-open service. -
Explicit opt-out:
VMAFX_MCP_HTTP_NO_AUTH=1disables authentication (for deployments that sit behind a network-level gateway). A startup warning is logged. -
Loopback-only bind default.
_resolve_bind_host()returns127.0.0.1whenVMAFX_MCP_HTTP_BINDis unset. Operators who need pod-network or host-network reachability setVMAFX_MCP_HTTP_BIND=0.0.0.0explicitly. -
Optional TLS. If
VMAFX_MCP_HTTP_TLS_CERTandVMAFX_MCP_HTTP_TLS_KEYare both set, anssl.SSLContextis built and passed toaiohttp.web.TCPSite. If either is absent, a startup warning is logged (HTTP-only mode).
The Unix-domain-socket transport (default stdio / UDS) is unaffected by all of the above.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| No auth (status quo) | Zero operator friction | Any reachable client has full API access; violates OWASP API Security Top 10 (API1:2023 Broken Object Level Authorization / API2:2023 Broken Authentication) | Rejected — unacceptable for a production service |
Path-allowlist auth (restrict /v1/score only) | Simple to implement | Health probes and /metrics still unauthenticated; attacker can still enumerate endpoints; partial solutions invite operator confusion | Rejected — auth must be uniform; a health probe leaks "is this host running vmafx" to unauthenticated callers |
| Bearer token (chosen) | Zero new deps; widely understood by HTTP clients; easy to rotate; composable with network-layer TLS or mTLS | Shared secret; token rotation requires restart or reload; not suitable for multi-tenant key-per-client scenarios | Accepted — best fit for single-operator deployment model; multi-tenant can be layered via a gateway |
| mTLS only | Strongest identity guarantee; eliminates shared-secret risks | Requires PKI and certificate provisioning; not available in edge/dev deployments; significantly higher operator burden | Deferred — can be added as a follow-on via ssl.SSLContext.verify_mode = ssl.CERT_REQUIRED; the TLS scaffolding in this ADR already loads the SSLContext, so mTLS is a one-flag change |
Consequences¶
- Positive: HTTP transport is now fail-closed on auth; body-exhaustion DoS is bounded at 4 MiB; default bind is safe for local dev and container deployments without an explicit firewall rule.
- Negative: Breaking bind change — existing deployments that relied on
0.0.0.0default must addVMAFX_MCP_HTTP_BIND=0.0.0.0. Operators who have not configured a token see 401 for all traffic (by design — better than silent open access). - Neutral / follow-ups: TLS is scaffolded but optional; mTLS requires no additional scaffolding — add
ssl_context.verify_mode = ssl.CERT_REQUIRED+ssl_context.load_verify_locations(ca)if desired.VMAFX_MCP_HTTP_NO_AUTH=1must be documented indeploy/helm/vmafx/values.yamlfor operators using network-layer auth gateways.
References¶
- ADR-0701 — HTTP transport foundation (introduces the transport this ADR hardens).
- OWASP API Security Top 10 — API2:2023 Broken Authentication, API4:2023 Unrestricted Resource Consumption.
- Source: Round 26 security audit finding A.1 (user direction, 2026-05-31).