mcp: add automatic DNS rebinding protection for localhost servers (#760)
## 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>
This commit is contained in:
parent
c952ab0a1c
commit
67bd3f2e2b
5 changed files with 203 additions and 2 deletions
|
|
@ -1,5 +1,4 @@
|
|||
server:
|
||||
- dns-rebinding-protection
|
||||
server: [] # All tests pass!
|
||||
client:
|
||||
- auth/basic-cimd
|
||||
- auth/metadata-default
|
||||
|
|
|
|||
26
internal/util/net.go
Normal file
26
internal/util/net.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// 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()
|
||||
}
|
||||
35
internal/util/net_test.go
Normal file
35
internal/util/net_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// 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 "testing"
|
||||
|
||||
// TestIsLoopback tests the IsLoopback helper function.
|
||||
func TestIsLoopback(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{"localhost", true},
|
||||
{"localhost:3000", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.1:3000", true},
|
||||
{"[::1]", true},
|
||||
{"[::1]:3000", true},
|
||||
{"::1", true},
|
||||
{"", false},
|
||||
{"evil.com", false},
|
||||
{"evil.com:80", false},
|
||||
{"localhost.evil.com", false},
|
||||
{"127.0.0.1.evil.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
if got := IsLoopback(tt.addr); got != tt.want {
|
||||
t.Errorf("IsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
|
@ -30,6 +31,8 @@ import (
|
|||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/xcontext"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
|
@ -161,6 +164,16 @@ type StreamableHTTPOptions struct {
|
|||
//
|
||||
// If SessionTimeout is the zero value, idle sessions are never closed.
|
||||
SessionTimeout time.Duration
|
||||
|
||||
// DisableLocalhostProtection disables automatic DNS rebinding protection.
|
||||
// By default, requests arriving via a localhost address (127.0.0.1, [::1])
|
||||
// that have a non-localhost Host header are rejected with 403 Forbidden.
|
||||
// This protects against DNS rebinding attacks regardless of whether the
|
||||
// server is listening on localhost specifically or on 0.0.0.0.
|
||||
//
|
||||
// Only disable this if you understand the security implications.
|
||||
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
|
||||
DisableLocalhostProtection bool
|
||||
}
|
||||
|
||||
// NewStreamableHTTPHandler returns a new [StreamableHTTPHandler].
|
||||
|
|
@ -207,7 +220,24 @@ func (h *StreamableHTTPHandler) closeAll() {
|
|||
}
|
||||
}
|
||||
|
||||
// disablelocalhostprotection is a compatibility parameter that allows to disable
|
||||
// DNS rebinding protection, which was added in the 1.4.0 version of the SDK.
|
||||
// See the documentation for the mcpgodebug package for instructions how to enable it.
|
||||
// The option will be removed in the 1.6.0 version of the SDK.
|
||||
var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")
|
||||
|
||||
func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// DNS rebinding protection: auto-enabled for localhost servers.
|
||||
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
|
||||
if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" {
|
||||
if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil {
|
||||
if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) {
|
||||
http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow multiple 'Accept' headers.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax
|
||||
accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",")
|
||||
|
|
|
|||
|
|
@ -2374,3 +2374,114 @@ func Test_ExportErrSessionMissing(t *testing.T) {
|
|||
t.Errorf("expected error to wrap ErrSessionMissing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableLocalhostProtection verifies that DNS rebinding protection
|
||||
// is automatically enabled for localhost servers.
|
||||
func TestStreamableLocalhostProtection(t *testing.T) {
|
||||
server := NewServer(testImpl, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listenAddr string // Address to listen on
|
||||
hostHeader string // Host header in request
|
||||
disableProtection bool // DisableLocalhostProtection setting
|
||||
wantStatus int
|
||||
}{
|
||||
// Auto-enabled for localhost listeners (127.0.0.1).
|
||||
{
|
||||
name: "127.0.0.1 accepts 127.0.0.1",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "127.0.0.1:1234",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 accepts localhost",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "localhost:1234",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 rejects evil.com",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "evil.com",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 rejects evil.com:80",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "evil.com:80",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 rejects localhost.evil.com",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "localhost.evil.com",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
|
||||
// When listening on 0.0.0.0, requests arriving via localhost are still protected
|
||||
// because LocalAddrContextKey returns the actual connection's local address.
|
||||
// This is actually more secure - DNS rebinding attacks target localhost regardless
|
||||
// of the listener configuration.
|
||||
{
|
||||
name: "0.0.0.0 via localhost rejects evil.com",
|
||||
listenAddr: "0.0.0.0:0",
|
||||
hostHeader: "evil.com",
|
||||
disableProtection: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
|
||||
// Explicit disable
|
||||
{
|
||||
name: "disabled accepts evil.com",
|
||||
listenAddr: "127.0.0.1:0",
|
||||
hostHeader: "evil.com",
|
||||
disableProtection: true,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := &StreamableHTTPOptions{
|
||||
Stateless: true, // Simpler for testing
|
||||
DisableLocalhostProtection: tt.disableProtection,
|
||||
}
|
||||
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts)
|
||||
|
||||
listener, err := net.Listen("tcp", tt.listenAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to listen on %s: %v", tt.listenAddr, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
srv := &http.Server{Handler: handler}
|
||||
go srv.Serve(listener)
|
||||
defer srv.Close()
|
||||
|
||||
reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s", listener.Addr().String()), reqReader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = tt.hostHeader
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if got := resp.StatusCode; got != tt.wantStatus {
|
||||
t.Errorf("Status code: got %d, want %d", got, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue