Ctrl K
ComunidadGuías…

internal/mcpgateway/originguard_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcpgateway

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

	"github.com/enterpilot/gomodel/internal/core"
	"github.com/stretchr/testify/require"
)

func TestOriginGuardCheck(t *testing.T) {
	tests := []struct {
		name    string
		trusted []string
		method  string
		headers map[string]string
		allowed bool
	}{
		{
			name:    "mcp client sends no browser metadata",
			method:  http.MethodPost,
			allowed: true,
		},
		{
			name:    "dns rebound request has a self-consistent origin and host",
			method:  http.MethodPost,
			headers: map[string]string{"Host": "attacker.example:8080", "Origin": "http://attacker.example:8080"},
			allowed: false,
		},
		{
			name:   "dns rebound request reports itself as same-origin",
			method: http.MethodPost,
			headers: map[string]string{
				"Host": "attacker.example:8080", "Origin": "http://attacker.example:8080",
				"Sec-Fetch-Site": "same-origin",
			},
			allowed: false,
		},
		{
			name:    "GET opens the notification stream and is checked too",
			method:  http.MethodGet,
			headers: map[string]string{"Host": "attacker.example:8080", "Origin": "http://attacker.example:8080", "Sec-Fetch-Site": "same-origin"},
			allowed: false,
		},
		{
			name:    "DELETE tears a session down and is checked too",
			method:  http.MethodDelete,
			headers: map[string]string{"Host": "attacker.example:8080", "Origin": "http://attacker.example:8080"},
			allowed: false,
		},
		{
			name:    "browser metadata without any origin cannot be trusted",
			method:  http.MethodPost,
			headers: map[string]string{"Sec-Fetch-Site": "none"},
			allowed: false,
		},
		{
			name:    "opaque origin never matches",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "null"},
			allowed: false,
		},
		{
			name:    "trusted origin is served",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "https://console.example.com", "Sec-Fetch-Site": "same-origin"},
			allowed: true,
		},
		{
			name:    "trusted origin matches case-insensitively",
			trusted: []string{"https://Console.Example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "HTTPS://console.example.COM"},
			allowed: true,
		},
		{
			name:    "trusting one origin does not trust a look-alike",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "https://console.example.com.attacker.example"},
			allowed: false,
		},
		{
			name:    "port is part of the origin",
			trusted: []string{"http://localhost:3000"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "http://localhost:3001"},
			allowed: false,
		},
		{
			name:    "scheme is part of the origin",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "http://console.example.com"},
			allowed: false,
		},
		{
			name:    "allowlisted host may open the origin-less same-origin stream",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "same-origin"},
			allowed: true,
		},
		{
			name:    "HEAD is treated like the stream GET",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodHead,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "same-origin"},
			allowed: true,
		},
		{
			name:    "allowlisted host on a non-default port matches the Host header",
			trusted: []string{"http://localhost:3000"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "localhost:3000", "Sec-Fetch-Site": "same-origin"},
			allowed: true,
		},
		{
			name:    "rebound host is not on the allowlist so the stream is refused",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "attacker.example:8080", "Sec-Fetch-Site": "same-origin"},
			allowed: false,
		},
		{
			name:    "origin-less stream is refused when nothing is allowlisted",
			method:  http.MethodGet,
			headers: map[string]string{"Host": "gateway.internal:8080", "Sec-Fetch-Site": "same-origin"},
			allowed: false,
		},
		{
			name:    "same-site does not attest the initiating origin",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "same-site"},
			allowed: false,
		},
		{
			name:    "cross-site does not attest the initiating origin",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "cross-site"},
			allowed: false,
		},
		{
			name:    "a bare navigation to the endpoint is still refused",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "none"},
			allowed: false,
		},
		{
			name:    "the host fallback never applies to unsafe methods",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Host": "console.example.com", "Sec-Fetch-Site": "same-origin"},
			allowed: false,
		},
		{
			name:    "an explicitly rejected Origin is not rescued by a trusted Host",
			trusted: []string{"https://console.example.com"},
			method:  http.MethodGet,
			headers: map[string]string{"Host": "console.example.com", "Origin": "https://attacker.example", "Sec-Fetch-Site": "same-origin"},
			allowed: false,
		},
		{
			name:    "default https port is canonicalized on both sides",
			trusted: []string{"https://console.example.com:443"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "https://console.example.com"},
			allowed: true,
		},
		{
			name:    "default http port is canonicalized on both sides",
			trusted: []string{"http://console.example.com"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "http://console.example.com:80"},
			allowed: true,
		},
		{
			name:    "a default port is not stripped for the other scheme",
			trusted: []string{"https://console.example.com:80"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "https://console.example.com"},
			allowed: false,
		},
		{
			name:    "wildcard trusts every origin",
			trusted: []string{"*"},
			method:  http.MethodPost,
			headers: map[string]string{"Origin": "http://attacker.example:8080", "Sec-Fetch-Site": "same-origin"},
			allowed: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			guard, err := newOriginGuard(tt.trusted)
			require.NoError(t, err)

			req := httptest.NewRequest(tt.method, "/mcp", nil)
			for key, value := range tt.headers {
				if key == "Host" {
					req.Host = value
					continue
				}
				req.Header.Set(key, value)
			}

			err = guard.check(req)
			if tt.allowed {
				require.NoError(t, err)
				return
			}
			require.Error(t, err)

			var gatewayErr *core.GatewayError
			require.ErrorAs(t, err, &gatewayErr)
			require.Equal(t, http.StatusForbidden, gatewayErr.StatusCode)

			if origin := tt.headers["Origin"]; origin != "" {
				require.NotContains(t, gatewayErr.Message, origin, "message echoes the rejected origin")
			}
		})
	}
}

func TestNewOriginGuardRejectsMalformedEntries(t *testing.T) {
	for _, entry := range []string{
		"console.example.com",              // no scheme
		"https://",                         // no host
		"https://console.example.com/mcp",  // path
		"https://console.example.com?a=b",  // query
		"https://user@console.example.com", // userinfo
	} {
		t.Run(entry, func(t *testing.T) {
			_, err := newOriginGuard([]string{entry})
			require.Error(t, err)
		})
	}
}

func TestNewOriginGuardIgnoresBlankEntries(t *testing.T) {
	guard, err := newOriginGuard([]string{"", "  "})
	require.NoError(t, err)

	req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
	req.Header.Set("Origin", "http://attacker.example:8080")
	require.Error(t, guard.check(req))
}

// nonLoopbackAddr is the local address of a gateway bound to all interfaces,
// which is what `net.Listen("tcp", ":8080")` produces for a LAN caller. The
// MCP SDK's own DNS-rebinding guard is scoped to loopback local addresses by
// design, so it never engages here and the gateway's guard is the only
// defense — the exact condition the advisory describes.
type nonLoopbackAddr struct{}

func (nonLoopbackAddr) Network() string { return "tcp" }
func (nonLoopbackAddr) String() string  { return "192.0.2.10:8080" }

const initializeFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
	`{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"0"}}}`

func newInitializeRequest(t *testing.T) *http.Request {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(initializeFrame))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")
	return req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, nonLoopbackAddr{}))
}

// TestServiceRejectsReboundRequestOnNonLoopbackBind reproduces the advisory's
// proof of concept: a self-consistent forged Host/Origin pair, no credential,
// arriving on a non-loopback local address.
func TestServiceRejectsReboundRequestOnNonLoopbackBind(t *testing.T) {
	upstream := newTestUpstream(t, "alpha", addEchoTool("ping"))
	service, _ := newTestService(t, nil, testSpec("alpha", upstream, nil))

	req := newInitializeRequest(t)
	req.Host = "attacker.example:8080"
	req.Header.Set("Origin", "http://attacker.example:8080")
	req.Header.Set("Sec-Fetch-Site", "same-origin")

	rec := httptest.NewRecorder()
	err := service.ServeHTTP(rec, req, "")
	require.Error(t, err)

	var gatewayErr *core.GatewayError
	require.ErrorAs(t, err, &gatewayErr)
	require.Equal(t, http.StatusForbidden, gatewayErr.StatusCode)
	sessionID := rec.Header().Get("Mcp-Session-Id")
	require.Empty(t, sessionID)
}

// TestServiceServesMCPClientOnNonLoopbackBind is the other half: the guard
// must not cost ordinary MCP clients, which send no browser fetch metadata,
// their session on that same bind.
func TestServiceServesMCPClientOnNonLoopbackBind(t *testing.T) {
	upstream := newTestUpstream(t, "alpha", addEchoTool("ping"))
	service, _ := newTestService(t, nil, testSpec("alpha", upstream, nil))

	req := newInitializeRequest(t)
	req.Host = "gateway.internal:8080"

	rec := httptest.NewRecorder()
	err := service.ServeHTTP(rec, req, "")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, rec.Code, "status = %d, want %d (body %q)", rec.Code, http.StatusOK, rec.Body.String())
	require.NotEmpty(t, rec.Header().Get("Mcp-Session-Id"))
}