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 } }