mcp: add DNS rebinding and cross origin protections to SSE transport (#891)

Similar protections were already introduced before to the Streamable
transport.
This commit is contained in:
Maciej Kisiel 2026-04-14 11:36:20 +02:00 • committed by GitHub
commit 15e93a231c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -10,11 +10,13 @@ import (
"crypto/rand"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
@ -52,9 +54,25 @@ type SSEHandler struct {
}
// SSEOptions specifies options for an [SSEHandler].
// for now, it is empty, but may be extended in future.
// https://github.com/modelcontextprotocol/go-sdk/issues/507
type SSEOptions struct{}
type SSEOptions struct {
// 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
// CrossOriginProtection allows to customize cross-origin protection.
// The deny handler set in the CrossOriginProtection through SetDenyHandler
// is ignored.
// If nil, default (zero-value) cross-origin protection will be used.
// Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter
// to disable the default protection until v1.7.0.
CrossOriginProtection *http.CrossOriginProtection
}
// NewSSEHandler returns a new [SSEHandler] that creates and manages MCP
// sessions created via incoming HTTP requests.
@ -79,6 +97,10 @@ func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptio
s.opts = *opts
}
if s.opts.CrossOriginProtection == nil {
s.opts.CrossOriginProtection = &http.CrossOriginProtection{}
}
return s
}
@ -179,9 +201,34 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) {
}
func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
sessionID := req.URL.Query().Get("sessionid")
// 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
}
}
}
// TODO: consider checking Content-Type here. For now, we are lax.
if disablecrossoriginprotection != "1" {
// Verify the 'Origin' header to protect against CSRF attacks.
if err := h.opts.CrossOriginProtection.Check(req); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
// Validate 'Content-Type' header.
if req.Method == http.MethodPost {
contentType := req.Header.Get("Content-Type")
if contentType != "application/json" {
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
return
}
}
}
sessionID := req.URL.Query().Get("sessionid")
// For POST requests, the message body is a message to send to a session.
if req.Method == http.MethodPost {