package mcphttp_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-ecore/mcphttp"
)
const testOrigin = "https://mcp.example.org"
// okHandler is what the guard protects: anything that reaches it answers 200, so
// a test's status code says whether the guard let the request through.
func okHandler(reached *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if reached != nil {
*reached = true
}
w.WriteHeader(http.StatusOK)
})
}
func guard(t *testing.T, origin string, reached *bool) http.Handler {
t.Helper()
h, err := mcphttp.HostGuard(okHandler(reached), origin)
require.NoError(t, err)
return h
}
// TestProxiedHostIsAccepted pins the deployment shape, which is invisible
// otherwise: the daemon listens on loopback and nginx forwards the instance's
// public Host. Without this the endpoint could be guarded into refusing every
// production request while passing every local check.
func TestProxiedHostIsAccepted(t *testing.T) {
for _, host := range []string{"mcp.example.org", "mcp.example.org:443", "MCP.EXAMPLE.ORG"} {
t.Run(host, func(t *testing.T) {
reached := false
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Host = host
rec := httptest.NewRecorder()
guard(t, testOrigin, &reached).ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached, "the guard must pass the instance's own host through")
})
}
}
// TestUnexpectedHostIsRefused is the guard doing its job: a rebinding attack
// carries a name the attacker controls, and the suffix case is here because a
// check written with HasSuffix instead of an equality would let it in.
func TestUnexpectedHostIsRefused(t *testing.T) {
for _, host := range []string{
"evil.example",
"mcp.example.org.evil.example",
"evilmcp.example.org",
"10.0.0.5",
"10.0.0.5:5093",
"",
} {
t.Run(host, func(t *testing.T) {
reached := false
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Host = host
rec := httptest.NewRecorder()
guard(t, testOrigin, &reached).ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached, "a refused request must not reach the handler behind the guard")
})
}
}
// TestLoopbackHostsStayAllowed is the concession the guard makes on purpose: a
// developer running the daemon by hand, and a local MCP client pointed at it,
// address it by a loopback name, and no attacker's page can carry one.
func TestLoopbackHostsStayAllowed(t *testing.T) {
for _, host := range []string{"localhost", "localhost:5093", "127.0.0.1", "127.0.0.1:5093", "[::1]", "[::1]:5093"} {
t.Run(host, func(t *testing.T) {
reached := false
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Host = host
rec := httptest.NewRecorder()
guard(t, testOrigin, &reached).ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
})
}
}
// TestAnOriginWithNoHostIsAConstructionError is the fail-closed stance, pinned.
// The alternative a donor took — warn and serve unguarded — is a service that
// works, which is exactly why nobody discovers it; this must be an error a
// daemon cannot start through.
func TestAnOriginWithNoHostIsAConstructionError(t *testing.T) {
for name, origin := range map[string]string{
"empty": "",
"blank": " ",
"schemeless": "mcp.example.org",
"path only": "/mcp",
"unparseable": "://mcp.example.org",
"scheme alone": "https://",
} {
t.Run(name, func(t *testing.T) {
h, err := mcphttp.HostGuard(okHandler(nil), origin)
require.Error(t, err)
assert.Nil(t, h, "a refused guard must not hand back a handler somebody could mount anyway")
assert.ErrorIs(t, err, mcphttp.ErrNoOriginHost)
assert.Contains(t, err.Error(), origin, "the error must quote the origin an operator has to fix")
})
}
}
// TestErrNoOriginHostIsMatchable states the contract callers rely on: they
// classify with errors.Is, not by reading the message.
func TestErrNoOriginHostIsMatchable(t *testing.T) {
_, err := mcphttp.HostGuard(okHandler(nil), "")
require.Error(t, err)
assert.True(t, errors.Is(err, mcphttp.ErrNoOriginHost))
}
// TestARefusalByHostnameIsUncacheable pins the composition the package
// documents: PrivateCache wraps HostGuard, so a 403 written before the SDK is
// reached at all still carries the directives. A cached 403 would be its own
// bug, and the ordering that prevents it is easy to reverse by accident.
func TestARefusalByHostnameIsUncacheable(t *testing.T) {
h := mcphttp.PrivateCache(guard(t, testOrigin, nil))
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Host = "evil.example"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, "private, no-store, no-transform", rec.Header().Get("Cache-Control"))
assert.Equal(t, "Cookie, Authorization", rec.Header().Get("Vary"))
}