~bigbes/sr-ht-ecore

ref: b36a927213562090e100c8b43ab27ef9094b1de9 sr-ht-ecore/mcphttp/hostguard_test.go -rw-r--r-- 5.0 KiB
b36a9272 — Eugene Blikh beads: ignore the JSONL exports a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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"))
}