Research digest: MCP HTTP transport security hardening (ADR-0967, 2026-05-31)¶
Scope: Round 26 audit finding A.1 — three security gaps in mcp-server/vmaf-mcp/src/vmaf_mcp/http_transport.py.
Finding summary¶
| ID | Severity | Finding | Disposition |
|---|---|---|---|
| A.1.1 | High | _handle_score calls await request.json() with no body-size limit | Fixed — client_max_size + Content-Length pre-flight middleware (ADR-0967) |
| A.1.2 | High | No Authorization / token enforcement on any endpoint | Fixed — Bearer token middleware, fail-closed default (ADR-0967) |
| A.1.3 | Medium | _serve() binds 0.0.0.0 unconditionally | Fixed — default changed to 127.0.0.1; opt-out via VMAFX_MCP_HTTP_BIND (ADR-0967) |
| A.1.4 | Low | No TLS scaffolding | Addressed — optional TLS via VMAFX_MCP_HTTP_TLS_CERT + VMAFX_MCP_HTTP_TLS_KEY; warns on startup if absent |
Body-size limit design¶
Two mechanisms are needed because HTTP allows bodies without a Content-Length header (chunked transfer encoding):
-
Pre-flight gate: the security middleware checks
request.content_lengthbefore calling the next handler. If declared size > 4 MiB, returns 413 immediately with no body bytes read. This is the fast path for well-behaved clients. -
Stream gate:
client_max_size=4*1024*1024on theaiohttp.web.Applicationcaps the total bytes buffered byrequest.read()/request.json(). When the limit is exceeded, aiohttp raisesHTTPRequestEntityTooLargewhich becomes a 413 response. This covers chunked / unknown-length bodies.
The 4 MiB limit was chosen to accommodate the largest realistic VMAF request payload (a JSON list of per-frame metadata for a 2-hour UHD source at 30 fps: ~6 400 frames × ~50 bytes/entry ≈ 320 KB). 4 MiB is an order of magnitude above the realistic maximum and well below the point where server OOM becomes a concern.
Auth design rationale¶
Fail-closed default: when VMAFX_MCP_HTTP_TOKEN is unset and VMAFX_MCP_HTTP_NO_AUTH is also unset, the server rejects every request with 401. The rationale: an operator who deploys the server without reading the docs sees a clear 401 with an actionable error message, rather than accidentally running an open service.
No health-probe exemption: Kubernetes liveness / readiness probes from within the cluster are implicitly trusted by the cluster network topology. Exempting /healthz and /readyz from auth would leak "is this pod running vmafx" to any unauthenticated caller on the same network. Operators who need unauthenticated health checks should run probes via the Kubelet exec handler or use VMAFX_MCP_HTTP_NO_AUTH=1 when behind a gateway that handles auth.
Token comparison: auth_header[len("Bearer "):] != expected_token uses Python's built-in string != which is not constant-time. This is intentional: the bearer token is a shared operator secret (a random string, not a MAC), not a cryptographic key used for signing. Timing-based attacks on equality comparisons require an adversary with sub-millisecond response-time measurement capability and direct access to the endpoint — a deployment model that already implies the auth gateway has been bypassed. Adding hmac.compare_digest() is low-cost and could be added as a follow-on.
Bind default change impact¶
The pre-ADR-0967 default was 0.0.0.0 (all interfaces). The new default is 127.0.0.1 (loopback only).
Affected deployment patterns:
- Docker
docker run: must add-e VMAFX_MCP_HTTP_BIND=0.0.0.0or map port via--publish 127.0.0.1:8080:8080if the container needs to receive traffic from the host or other containers. - Kubernetes pod: must set
env: [{name: VMAFX_MCP_HTTP_BIND, value: "0.0.0.0"}]since pod-to-pod traffic traverses the pod network which is external to the loopback interface. - Helm chart:
deploy/helm/vmafx/values.yamlshould document the override; the Helm chart's default values file ships with the env var set. - Local dev / IDE: loopback default is exactly right — the MCP client and server run on the same host.
Test coverage (23 tests total)¶
All 23 tests in tests/test_http_transport.py pass after this change, including 13 new security-focused tests:
| Test | Covers |
|---|---|
test_unauthenticated_request_rejected | Token configured, no header → 401 |
test_wrong_token_rejected | Token configured, wrong Bearer → 401 |
test_correct_token_accepted | Token configured, correct Bearer → 200 |
test_no_token_configured_rejects_all | No token, no NO_AUTH → 401 |
test_body_too_large_rejected_via_content_length | Content-Length > 4 MiB → 413 |
test_body_at_limit_accepted | Content-Length = 4 MiB → passes middleware |
test_explicit_no_auth_allows_anonymous | NO_AUTH=1 → 200 without header |
test_default_bind_is_loopback | _resolve_bind_host() returns 127.0.0.1 |
test_explicit_bind_overrides_default | VMAFX_MCP_HTTP_BIND=0.0.0.0 respected |
test_resolve_auth_token_returns_none_when_unset | Unit: token resolver |
test_resolve_auth_token_returns_value_when_set | Unit: token resolver |
test_no_auth_mode_false_by_default | Unit: NO_AUTH resolver |
test_no_auth_mode_true_when_set | Unit: NO_AUTH resolver |
The original 10 smoke tests (health, readyz, metrics, score) continue to pass via the test_client fixture which runs in NO_AUTH=1 mode.