## Summary Add DNS rebinding protection that is automatically enabled when requests arrive via localhost (127.0.0.1, [::1]). This protects against malicious websites using DNS rebinding to interact with local MCP servers. (note: this was a claude assisted PR, mostly wanted to see how difficult passing this test would be for this SDK) ## Design Goal: Secure by Default The primary goal is to make it difficult to run a localhost server without these protections by mistake. There are other approaches that could provide secure defaults (e.g., a helper for `ListenAndServe` that does the localhost check), but using `http.LocalAddrContextKey` for runtime detection seemed like the most backwards compatible approach and least likely to be disabled by accident. With this implementation: - **No code changes required** - existing servers get protection automatically - **No opt-in needed** - protection activates based on the connection's local address - **Explicit opt-out** - users must deliberately set `DisableLocalhostProtection: true` ## Changes - Add `DisableLocalhostProtection` option to `StreamableHTTPOptions` - Add `isLocalhostAddr` and `isLocalhostHost` helper functions - Validate Host header at start of `ServeHTTP`, rejecting non-localhost Host headers with 403 Forbidden ## How it works The protection uses `http.LocalAddrContextKey` to detect the connection's local address at runtime. When a request arrives via localhost (127.0.0.1 or [::1]), the handler validates that the Host header also matches a localhost value. If not, the request is rejected with 403 Forbidden. This approach means: - Protection is enabled for requests arriving via localhost, regardless of whether the server listens on `127.0.0.1` or `0.0.0.0` - Requests arriving via non-localhost IPs (e.g., external network requests) are **not** affected ### Edge case: Reverse proxies If a reverse proxy (e.g., Envoy, nginx) runs on the same host and forwards requests to the MCP server via localhost while preserving the original Host header, those requests would be rejected. In this case, users should either: 1. Set `DisableLocalhostProtection: true` 2. Configure the proxy to rewrite the Host header to localhost ## Testing - Added unit tests for `isLocalhostAddr` and `isLocalhostHost` helper functions - Added integration tests for the full protection flow - Verified against the MCP conformance test suite: - `localhost-host-rebinding-rejected`: PASS - `localhost-host-valid-accepted`: PASS ## Related - Spec: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise - TypeScript SDK implementation: `localhostHostValidation()` middleware - Conformance test: `dns-rebinding-protection` scenario --------- Co-authored-by: Maciek Kisiel <mkisiel@google.com>
26 lines
554 B
Go
26 lines
554 B
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by the license
|
|
// that can be found in the LICENSE file.
|
|
package util
|
|
|
|
import (
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
)
|
|
|
|
func IsLoopback(addr string) bool {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
// If SplitHostPort fails, it might be just a host without a port.
|
|
host = strings.Trim(addr, "[]")
|
|
}
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
ip, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return ip.IsLoopback()
|
|
}
|