Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions changelog.d/fixed/bughunt-mcp-go-python-parity-2026-06-27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
- MCP server: port the Python ADR-0967 HTTP hardening to the Go
`cmd/vmafx-mcp` streamable-HTTP transport. New `http_security.go` adds a
bearer-token auth middleware (`VMAFX_MCP_HTTP_TOKEN`, constant-time compare,
`VMAFX_MCP_HTTP_NO_AUTH=1` opt-out, refuse-all when neither is set), a 4 MiB
request-body limit (`http.MaxBytesReader` + Content-Length pre-flight → 413),
and a loopback-only default bind (`VMAFX_MCP_HTTP_BIND`, default `127.0.0.1`,
applied when `mcp.http.addr` carries no explicit host). The Go HTTP transport
was previously unauthenticated, all-interfaces, and unbounded — diverging
from the locked-down Python server.
- MCP server: align the score-precision default across all paths to `legacy`
(`%.6f`, the documented C-CLI default per ADR-0119). The Python HTTP
`/v1/score` path and the Go direct-cgo→subprocess fallback both defaulted to
`"17"`, so a client got a different numeric format depending on which
transport / dispatch path served the request.
- MCP server (Go `eval_model_on_split`): add the pred/target shape-mismatch
guard the Python `_eval_model_on_split` already carries, so a model whose
output rank does not match the target vector returns a clear `error` JSON
instead of a misleading `pearsonr` failure or a silently-broadcast result.
- MCP server (Go vmaf-tune wrappers `run_compare` / `run_ladder` /
`run_tune_per_shot`): capture the subprocess stderr and fold it into the
wrapped error (`vmaf-tune <sub> exited <rc>: <stderr>`), mirroring the Python
wrappers. The wrappers previously used `exec.Output()` and discarded stderr,
losing the failure diagnostic vmaf-tune emits.
25 changes: 25 additions & 0 deletions cmd/vmafx-mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,28 @@ Key facts a future agent must keep straight:
`"vmaf returned exit 0 but score was null"`. `TestProbeYUVDimensions` /
`TestScoreIsHealthy` (Go) and `tests/test_probe_backend_pr850.py` (Python)
pin both sides.

13. **HTTP transport security parity** (ADR-0967): when `mcp.transport=http`,
the Go transport (`http_security.go::securityMiddleware` + `applyBindHost`,
wired in `main.go::runMCPTransport`) and the Python transport
(`http_transport.py::_make_security_middleware` + `_resolve_bind_host`) MUST
enforce the same hardening under the same env contract:
`VMAFX_MCP_HTTP_TOKEN` (bearer token, constant-time compare —
`crypto/subtle.ConstantTimeCompare` ↔ `hmac.compare_digest`),
`VMAFX_MCP_HTTP_NO_AUTH=1` (explicit opt-out), **refuse-all 401 when neither
is set**, a **4 MiB** request-body limit (`http.MaxBytesReader` +
Content-Length pre-flight → 413 ↔ `MAX_REQUEST_BODY_BYTES`), and a
loopback-only default bind (`VMAFX_MCP_HTTP_BIND`, default `127.0.0.1`). A
client must get the same accept/reject decision from either server.
`TestSecurityMiddleware*` / `TestApplyBindHost` (Go) and the
`tests/test_http_transport.py` security block (Python) pin both sides. Do
not relax the refuse-all default or widen the bind default without changing
BOTH servers and ADR-0967.

14. **Score-precision default parity** (ADR-0119 / ADR-1117): every MCP scoring
path — Go stdio/subprocess (`impl.go`), Go direct-cgo fallback
(`impl_direct.go`), Python stdio (`server.py`), and Python HTTP `/v1/score`
(`http_transport.py`) — MUST default the `precision` arg to `legacy`
(`%.6f`, the documented C-CLI default). Do not reintroduce a `"17"` default
on any single path: a client must get the same numeric format regardless of
which server / transport / dispatch path served the request.
150 changes: 150 additions & 0 deletions cmd/vmafx-mcp/http_security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright 2026 Lusoris. All rights reserved.
// Use of this source code is governed by the BSD-3-Clause-Plus-Patent
// license that can be found in the LICENSE file.

// http_security.go ports the Python MCP HTTP transport hardening (ADR-0967)
// to the Go streamable-HTTP transport: bearer-token auth, a request-body size
// limit, and a loopback-only bind default. The Go and Python servers expose
// the same HTTP surface to MCP clients, so the security posture must match —
// otherwise the Go server is an unauthenticated, all-interfaces, unbounded-body
// hole where the Python server is locked down.
//
// Environment contract (identical names to the Python transport so a single
// deployment config drives both, ADR-0967):
//
// VMAFX_MCP_HTTP_TOKEN Bearer token. When set (and NO_AUTH is unset),
// every request must carry
// `Authorization: Bearer <token>` matching it via a
// constant-time compare.
// VMAFX_MCP_HTTP_NO_AUTH Set to "1" to disable auth entirely (explicit
// operator opt-out). Any other value keeps auth on.
// VMAFX_MCP_HTTP_BIND Bind host. Defaults to 127.0.0.1 (loopback-only).
// Set to 0.0.0.0 to listen on all interfaces. Only
// applied when the configured listen address
// (mcp.http.addr) has no explicit host (e.g. ":3000").
//
// Auth-default semantics mirror the Python middleware exactly: when neither
// VMAFX_MCP_HTTP_TOKEN nor VMAFX_MCP_HTTP_NO_AUTH is set, the server refuses
// ALL traffic with 401 — a missing token means the operator has not set up
// auth, and rejecting is safer than silently accepting.
//
// These vars are read directly via os.Getenv (NOT through koanf): they share
// the secret-handling contract of VMAF_BIN / VMAFX_MCP_DIRECT and must not be
// logged or surfaced through the config dump.

package main

import (
"crypto/subtle"
"encoding/json"
"net"
"net/http"
"os"
"strings"
)

// maxRequestBodyBytes bounds the request body the HTTP transport accepts.
// Matches the Python transport's MAX_REQUEST_BODY_BYTES (4 MiB) so both servers
// reject the same oversized payloads.
const maxRequestBodyBytes int64 = 4 * 1024 * 1024 // 4 MiB

// resolveAuthToken returns the expected bearer token, or "" when unset/empty.
func resolveAuthToken() string {
return strings.TrimSpace(os.Getenv("VMAFX_MCP_HTTP_TOKEN"))
}

// noAuthMode reports whether VMAFX_MCP_HTTP_NO_AUTH=1 disables auth.
func noAuthMode() bool {
return strings.TrimSpace(os.Getenv("VMAFX_MCP_HTTP_NO_AUTH")) == "1"
}

// resolveBindHost returns the bind host, defaulting to 127.0.0.1 (loopback)
// per ADR-0967. Operators set VMAFX_MCP_HTTP_BIND=0.0.0.0 to listen on all
// interfaces.
func resolveBindHost() string {
if h := strings.TrimSpace(os.Getenv("VMAFX_MCP_HTTP_BIND")); h != "" {
return h
}
return "127.0.0.1"
}

// applyBindHost reconciles the configured listen address (mcp.http.addr, e.g.
// ":3000" or "0.0.0.0:3000") with the VMAFX_MCP_HTTP_BIND host default. When
// the configured address has no explicit host (host part empty, the ":3000"
// form, which net.Listen treats as all-interfaces), the loopback-only default
// from resolveBindHost() is substituted so the Go transport is not exposed on
// every interface by default. When the operator already pinned a host in
// mcp.http.addr, it wins (explicit config beats the env default).
func applyBindHost(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
// Not a host:port string (e.g. a bare port or malformed value); leave
// it to net.Listen to interpret / error on, unchanged.
return addr
}
if host == "" {
return net.JoinHostPort(resolveBindHost(), port)
}
return addr
}

// writeJSONError writes a JSON {"error": msg} body with the given status.
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// Best-effort: the connection may already be gone; nothing to recover.
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

// securityMiddleware wraps an http.Handler with the ADR-0967 body-size and
// bearer-auth gates, mirroring the Python _make_security_middleware exactly:
//
// - Body size: a Content-Length header exceeding maxRequestBodyBytes is
// rejected with 413 before the handler runs; the body itself is wrapped in
// http.MaxBytesReader so chunked / unknown-length bodies are capped too
// (the inner handler sees a read error once the cap is hit).
// - Auth: when VMAFX_MCP_HTTP_NO_AUTH=1, auth is skipped. Otherwise a missing
// VMAFX_MCP_HTTP_TOKEN means refuse-all (401). When a token is configured,
// the Authorization: Bearer <token> header must match it via a
// constant-time compare.
//
// Health/metrics routes are NOT exempt, matching the Python transport.
func securityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// --- Body-size pre-flight via Content-Length header -------------------
if r.ContentLength > maxRequestBodyBytes {
writeJSONError(w, http.StatusRequestEntityTooLarge,
"Request body too large: Content-Length exceeds limit")
return
}
// Cap chunked / unknown-length bodies too. MaxBytesReader makes a read
// past the limit fail inside the handler with a 413-shaped error.
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
}

// --- Auth gate --------------------------------------------------------
if !noAuthMode() {
expected := resolveAuthToken()
if expected == "" {
// No token configured and no explicit opt-out: refuse all
// traffic (safer than silently accepting). Mirrors Python.
writeJSONError(w, http.StatusUnauthorized,
"Unauthorized: server requires VMAFX_MCP_HTTP_TOKEN or VMAFX_MCP_HTTP_NO_AUTH=1")
return
}
const prefix = "Bearer "
authHeader := r.Header.Get("Authorization")
presented, ok := strings.CutPrefix(authHeader, prefix)
// Constant-time compare guards against token enumeration via
// wall-clock timing side-channels (matches hmac.compare_digest).
if !ok || subtle.ConstantTimeCompare([]byte(presented), []byte(expected)) != 1 {
writeJSONError(w, http.StatusUnauthorized,
"Unauthorized: invalid or missing Bearer token")
return
}
}

next.ServeHTTP(w, r)
})
}
132 changes: 132 additions & 0 deletions cmd/vmafx-mcp/http_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2026 Lusoris. All rights reserved.
// Use of this source code is governed by the BSD-3-Clause-Plus-Patent
// license that can be found in the LICENSE file.

package main

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// okHandler is a trivial downstream handler that records whether it ran and
// returns 200.
func okHandler(ran *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
*ran = true
w.WriteHeader(http.StatusOK)
})
}

func TestSecurityMiddlewareRefusesWhenNoTokenAndNoOptOut(t *testing.T) {
t.Setenv("VMAFX_MCP_HTTP_TOKEN", "")
t.Setenv("VMAFX_MCP_HTTP_NO_AUTH", "")

ran := false
h := securityMiddleware(okHandler(&ran))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil))

if rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401 when no token and no opt-out, got %d", rec.Code)
}
if ran {
t.Fatal("downstream handler ran despite refuse-all auth gate")
}
}

func TestSecurityMiddlewareNoAuthMode(t *testing.T) {
t.Setenv("VMAFX_MCP_HTTP_TOKEN", "")
t.Setenv("VMAFX_MCP_HTTP_NO_AUTH", "1")

ran := false
h := securityMiddleware(okHandler(&ran))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil))

if rec.Code != http.StatusOK {
t.Fatalf("want 200 with NO_AUTH=1, got %d", rec.Code)
}
if !ran {
t.Fatal("downstream handler did not run with auth disabled")
}
}

func TestSecurityMiddlewareBearerToken(t *testing.T) {
t.Setenv("VMAFX_MCP_HTTP_TOKEN", "s3cr3t")
t.Setenv("VMAFX_MCP_HTTP_NO_AUTH", "")

cases := []struct {
name string
header string
want int
}{
{"valid token", "Bearer s3cr3t", http.StatusOK},
{"wrong token", "Bearer nope", http.StatusUnauthorized},
{"missing header", "", http.StatusUnauthorized},
{"no bearer prefix", "s3cr3t", http.StatusUnauthorized},
{"empty bearer", "Bearer ", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ran := false
h := securityMiddleware(okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("status: want %d, got %d", tc.want, rec.Code)
}
if (rec.Code == http.StatusOK) != ran {
t.Fatalf("handler-ran mismatch: ran=%v code=%d", ran, rec.Code)
}
})
}
}

func TestSecurityMiddlewareBodyLimitContentLength(t *testing.T) {
t.Setenv("VMAFX_MCP_HTTP_TOKEN", "")
t.Setenv("VMAFX_MCP_HTTP_NO_AUTH", "1") // isolate the body-size gate

ran := false
h := securityMiddleware(okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("x"))
req.ContentLength = maxRequestBodyBytes + 1
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("want 413 for oversized Content-Length, got %d", rec.Code)
}
if ran {
t.Fatal("downstream handler ran despite oversized body")
}
}

func TestApplyBindHost(t *testing.T) {
cases := []struct {
name string
bindEnv string
addr string
wantAddr string
}{
{"no-host defaults loopback", "", ":3000", "127.0.0.1:3000"},
{"no-host honours bind env", "0.0.0.0", ":3000", "0.0.0.0:3000"},
{"explicit host wins over env", "0.0.0.0", "127.0.0.1:3000", "127.0.0.1:3000"},
{"explicit all-interfaces preserved", "", "0.0.0.0:8080", "0.0.0.0:8080"},
{"non host:port unchanged", "", "garbage", "garbage"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VMAFX_MCP_HTTP_BIND", tc.bindEnv)
if got := applyBindHost(tc.addr); got != tc.wantAddr {
t.Fatalf("applyBindHost(%q): want %q, got %q", tc.addr, tc.wantAddr, got)
}
})
}
}
Loading
Loading