~bigbes/sr-ht-ecore

ref: 54025f42346afbf561683c1d32c321ea875a421d sr-ht-ecore/mcphttp/hostguard.go -rw-r--r-- 3.9 KiB
54025f42 — Eugene Blikh ci: test, coverage and benchmarks on builds.sr.ht 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
package mcphttp

import (
	"errors"
	"fmt"
	"net"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
)

// ErrNoOriginHost is returned by [HostGuard] when the origin it was handed names
// no host to build an allowlist from — it is empty, it does not parse, or it
// parses to no hostname. Callers match it with errors.Is; the wrapped error
// quotes the origin.
var ErrNoOriginHost = errors.New("mcphttp: origin names no host")

// HostGuard is an MCP endpoint's DNS-rebinding protection in the form this
// deployment needs: Host must name the instance's own origin, or be a loopback
// name for local development.
//
// It is a wrapper rather than a check inside the handler so that the refusal
// happens before the SDK sees a byte of the body. It replaces the SDK's own
// guard, which [StreamableOptions] turns off — see there for why that trade is
// the safe direction.
//
// The expected host is [instconf.OriginHost]'s, which is the instance's one
// reading of "what host does this origin name": the name without the port, and
// "" for anything that does not parse. Never a guessed "localhost", which is
// what one earlier copy answered and which would have made every malformed
// origin agree with a local client on the one code path that decides an
// allowlist.
//
// # Fail-closed, and the stance that lost
//
// An origin with no host is a construction error here, not a warning. The two
// donors disagreed about this and the disagreement is worth recording rather
// than quietly resolving.
//
// cov.sr.ht refuses to build the surface at all: an origin is a required
// configuration key, config validation already requires it to parse and to carry
// a host, so a daemon that reached this call has one and a caller that did not
// is a bug rather than an operator to be warned. bench.sr.ht logs "Host
// validation on the MCP endpoint is DISABLED" and serves the endpoint
// unguarded, reasoning that refusing every request is a worse answer to a
// misconfiguration than checking no request.
//
// This package takes cov's side. The failure bench's stance produces is silent
// in exactly the deployment where it matters: the warning is one line at startup
// among many, and what follows it is a service that works — it answers every
// request, including the rebinding attack's. A service that refuses to start
// says the same thing in the one register nobody can scroll past. And the
// premise that made bench's choice cheap is the premise that makes it
// unnecessary: if config validation already requires an origin, the open path is
// unreachable by any real daemon, so keeping it buys nothing and costs a
// security property. bench has a test pinning its behaviour
// (TestNoOriginLeavesTheEndpointOpen) and retrofitting that is bench's to do,
// not this package's to force.
func HostGuard(next http.Handler, origin string) (http.Handler, error) {
	want := instconf.OriginHost(origin)
	if want == "" {
		return nil, fmt.Errorf("%w: %q has no host to guard the MCP endpoint with", ErrNoOriginHost, origin)
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !hostAllowed(r.Host, want) {
			http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	}), nil
}

// hostAllowed compares a request's Host against the expected hostname, ignoring
// any port and IPv6 brackets.
//
// Loopback names stay allowed on purpose: a developer running the daemon by hand
// and a local MCP client pointed at it address it as localhost, and those names
// cannot be a rebinding attack's — an attacker's page has to carry a name it
// controls.
func hostAllowed(reqHost, want string) bool {
	h := reqHost
	if stripped, _, err := net.SplitHostPort(h); err == nil {
		h = stripped
	}
	h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
	switch {
	case strings.EqualFold(h, want):
		return true
	case h == "localhost", h == "127.0.0.1", h == "::1":
		return true
	default:
		return false
	}
}