~bigbes/sr-ht-spec

ref: dd56e38c60d154ed0db6d60101a2fb92667438ea sr-ht-spec/mcpsrv/hostguard_test.go -rw-r--r-- 2.9 KiB
dd56e38c — Eugene Blikh refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4) 24 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
package mcpsrv_test

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

	"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 unusable origin must leave a loud trail rather than silently unguarding the
// endpoint. It still serves (refusing to start over a config typo is worse), but
// Handler logs a warning; this pins that it does not instead start refusing
// every request, which would look like the guard "working".
func TestHostGuardWithoutOriginStillServes(t *testing.T) {
	r, s := newFixture()
	h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", "")
	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 = "anything.example.com"
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")

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

	if w.Code == http.StatusForbidden {
		t.Fatalf("unconfigured origin should not 403 every request; got %d", w.Code)
	}
}