coverage~bigbes/sr-ht-ecoreb36a9272mcphttp/hostguard.go

Coverage
100.0% 16/16 statements
Δ
+0.0
Blob
e668e45
Uncovered nothing — every instrumented line ran
1 package mcphttp
2
3 import (
4 "errors"
5 "fmt"
6 "net"
7 "net/http"
8 "strings"
9
10 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
11 )
12
13 // ErrNoOriginHost is returned by [HostGuard] when the origin it was handed names
14 // no host to build an allowlist from — it is empty, it does not parse, or it
15 // parses to no hostname. Callers match it with errors.Is; the wrapped error
16 // quotes the origin.
17 var ErrNoOriginHost = errors.New("mcphttp: origin names no host")
18
19 // HostGuard is an MCP endpoint's DNS-rebinding protection in the form this
20 // deployment needs: Host must name the instance's own origin, or be a loopback
21 // name for local development.
22 //
23 // It is a wrapper rather than a check inside the handler so that the refusal
24 // happens before the SDK sees a byte of the body. It replaces the SDK's own
25 // guard, which [StreamableOptions] turns off — see there for why that trade is
26 // the safe direction.
27 //
28 // The expected host is [instconf.OriginHost]'s, which is the instance's one
29 // reading of "what host does this origin name": the name without the port, and
30 // "" for anything that does not parse. Never a guessed "localhost", which is
31 // what one earlier copy answered and which would have made every malformed
32 // origin agree with a local client on the one code path that decides an
33 // allowlist.
34 //
35 // # Fail-closed, and the stance that lost
36 //
37 // An origin with no host is a construction error here, not a warning. The two
38 // donors disagreed about this and the disagreement is worth recording rather
39 // than quietly resolving.
40 //
41 // cov.sr.ht refuses to build the surface at all: an origin is a required
42 // configuration key, config validation already requires it to parse and to carry
43 // a host, so a daemon that reached this call has one and a caller that did not
44 // is a bug rather than an operator to be warned. bench.sr.ht logs "Host
45 // validation on the MCP endpoint is DISABLED" and serves the endpoint
46 // unguarded, reasoning that refusing every request is a worse answer to a
47 // misconfiguration than checking no request.
48 //
49 // This package takes cov's side. The failure bench's stance produces is silent
50 // in exactly the deployment where it matters: the warning is one line at startup
51 // among many, and what follows it is a service that works — it answers every
52 // request, including the rebinding attack's. A service that refuses to start
53 // says the same thing in the one register nobody can scroll past. And the
54 // premise that made bench's choice cheap is the premise that makes it
55 // unnecessary: if config validation already requires an origin, the open path is
56 // unreachable by any real daemon, so keeping it buys nothing and costs a
57 // security property. bench has a test pinning its behaviour
58 // (TestNoOriginLeavesTheEndpointOpen) and retrofitting that is bench's to do,
59 // not this package's to force.
60 23 func HostGuard(next http.Handler, origin string) (http.Handler, error) {
61 23 want := instconf.OriginHost(origin)
62 23 if want == "" {
63 7 return nil, fmt.Errorf("%w: %q has no host to guard the MCP endpoint with", ErrNoOriginHost, origin)
64 7 }
65 16 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 16 if !hostAllowed(r.Host, want) {
67 7 http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden)
68 7 return
69 7 }
70 9 next.ServeHTTP(w, r)
71 }), nil
72 }
73
74 // hostAllowed compares a request's Host against the expected hostname, ignoring
75 // any port and IPv6 brackets.
76 //
77 // Loopback names stay allowed on purpose: a developer running the daemon by hand
78 // and a local MCP client pointed at it address it as localhost, and those names
79 // cannot be a rebinding attack's — an attacker's page has to carry a name it
80 // controls.
81 16 func hostAllowed(reqHost, want string) bool {
82 16 h := reqHost
83 16 if stripped, _, err := net.SplitHostPort(h); err == nil {
84 5 h = stripped
85 5 }
86 16 h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
87 16 switch {
88 3 case strings.EqualFold(h, want):
89 3 return true
90 6 case h == "localhost", h == "127.0.0.1", h == "::1":
91 6 return true
92 7 default:
93 7 return false
94 }
95 }