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