~bigbes/sr-ht-spec

ref: 3cb1c03d8078d5748cc13a2e9bd7ba7d078e1b37 sr-ht-spec/mcpsrv/hostguard_test.go -rw-r--r-- 3.6 KiB
3cb1c03d — Eugene Blikh go.mod: take the shared libraries' current heads 2 days 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
package mcpsrv_test

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

	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv"
)

// The SDK's own DNS-rebinding guard is disabled (it cannot tell nginx from an
// attacker), so this replacement is the only thing protecting /mcp. These cases
// are the reason the bypass is defensible: a hostile Host is still refused, and
// the legitimate proxied Host that the SDK guard would have rejected is let
// through.
func TestHostGuard(t *testing.T) {
	const origin = "https://spec.srht.bigb.es"

	for _, tc := range []struct {
		name string
		host string
		want int
	}{
		{"proxied real hostname", "spec.srht.bigb.es", http.StatusOK},
		{"proxied with port", "spec.srht.bigb.es:443", http.StatusOK},
		{"case-insensitive", "SPEC.SRHT.BIGB.ES", http.StatusOK},
		{"loopback for dev", "127.0.0.1:5091", http.StatusOK},
		{"localhost for dev", "localhost:5091", http.StatusOK},
		{"rebinding attacker domain", "evil.example.com", http.StatusForbidden},
		{"attacker subdomain of us", "spec.srht.bigb.es.evil.com", http.StatusForbidden},
		{"another srht service", "git.srht.bigb.es", http.StatusForbidden},
	} {
		t.Run(tc.name, func(t *testing.T) {
			r, s := newFixture()
			h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", origin)
			if err != nil {
				t.Fatalf("Handler: %v", err)
			}

			req := httptest.NewRequest(http.MethodPost, "/mcp",
				strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`))
			req.Host = tc.host
			req.Header.Set("Content-Type", "application/json")
			req.Header.Set("Accept", "application/json, text/event-stream")

			w := httptest.NewRecorder()
			h.ServeHTTP(w, req)

			if tc.want == http.StatusForbidden && w.Code != http.StatusForbidden {
				t.Fatalf("Host %q got %d, want 403 — the guard did not block it", tc.host, w.Code)
			}
			if tc.want == http.StatusOK && w.Code == http.StatusForbidden {
				t.Fatalf("Host %q got 403 — the guard blocked a legitimate request", tc.host)
			}
		})
	}
}

// An origin with no host in it is a construction error, not a warning.
//
// This used to be the opposite: Handler logged "Host validation on /mcp is
// DISABLED" and returned the bare handler, on the reasoning that refusing to
// start over a config typo is worse than running unguarded. It is not. The
// guard is the only thing protecting /mcp once the SDK's own is disabled, so
// that path turned one unparseable value into a silently open endpoint that
// passes every functional test — the class of failure nobody discovers. The
// daemon cannot reach it in any case: service.Config.Validate already refuses
// to start unless the origin parses and carries a host.
func TestHandlerRefusesAnOriginItCannotGuardWith(t *testing.T) {
	for _, origin := range []string{
		"",
		"   ",
		"not a url at all",
		"https://",   // parses, but carries no host
		"/just/path", // relative, no host
	} {
		t.Run(origin, func(t *testing.T) {
			r, s := newFixture()
			h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", origin)

			require.Error(t, err, "an origin with no host must not yield a handler")
			require.Nil(t, h, "an unguarded handler must never escape the constructor")
			require.Contains(t, err.Error(), "no host to guard /mcp with")
		})
	}
}

// A usable origin still builds, which is what keeps the test above from passing
// for the wrong reason.
func TestHandlerAcceptsAUsableOrigin(t *testing.T) {
	r, s := newFixture()
	h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", "https://spec.srht.bigb.es")
	require.NoError(t, err)
	require.NotNil(t, h)
}