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")) }