mcp: verify 'Origin' and 'Content-Type' headers (#842)

This PR introduces protection against cross-origin requests by
installing `http.CrossOriginProtection`.
By default, the zero value is used which does not contain any trusted
origins or bypass patterns.
The protection can be customized by providing a custom
`http.CrossOriginProtection` in `mcp.StreamableHTTPOptions`. Currently,
the deny handler set on the protection is ignored.
This default has an MCPGODEBUG option (`disableoriginverification`) to
disable the default protection.
It will be available until `v1.6.0` of the SDK.
We also increase the Go version required by the SDK to 1.25, since this
is the version that introduced `http.CrossOriginProtection`. This is in
line with the SDK's policy of supporting two newest Go versions (1.26
was released in February 2026).

Additionally, we start validating if the `Content-Type` header for
`POST` requests is set to `application/json` to avoid accepting
CORS-safelisted requests.
This commit is contained in:
Maciej Kisiel 2026-03-13 11:36:41 +01:00 • committed by GitHub
commit a433a831d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 252 additions and 11 deletions

View file

@ -174,6 +174,14 @@ type StreamableHTTPOptions struct {
// 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.6.0.
CrossOriginProtection *http.CrossOriginProtection
}
// NewStreamableHTTPHandler returns a new [StreamableHTTPHandler].
@ -190,8 +198,10 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea
h.opts = *opts
}
if h.opts.Logger == nil { // ensure we have a logger
h.opts.Logger = ensureLogger(nil)
h.opts.Logger = ensureLogger(h.opts.Logger)
if h.opts.CrossOriginProtection == nil {
h.opts.CrossOriginProtection = &http.CrossOriginProtection{}
}
return h
@ -226,6 +236,13 @@ func (h *StreamableHTTPHandler) closeAll() {
// The option will be removed in the 1.6.0 version of the SDK.
var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection")
// disablecrossoriginprotection is a compatibility parameter that allows to disable
// the verification of the 'Origin' and 'Content-Type' headers, which was added in
// the 1.4.1 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 disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection")
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
@ -238,6 +255,22 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
}
}
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
}
}
}
// 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"), ","), ",")

View file

@ -832,6 +832,36 @@ func TestStreamableServerTransport(t *testing.T) {
},
wantSessions: 0,
},
{
name: "content type headers",
requests: []streamableRequest{
initialize,
initialized,
{
// Request with incorrect Content-Type should be rejected.
method: "POST",
headers: http.Header{"Content-Type": {"text/plain"}},
messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})},
wantStatusCode: http.StatusUnsupportedMediaType,
},
{
// Request with empty Content-Type should be rejected.
method: "POST",
headers: http.Header{"Content-Type": {""}},
messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})},
wantStatusCode: http.StatusUnsupportedMediaType,
},
{
// Correct Content-Type should pass.
method: "POST",
headers: http.Header{"Content-Type": {"application/json"}},
messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})},
wantStatusCode: http.StatusOK,
wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)},
},
},
wantSessions: 1,
},
{
name: "accept headers",
requests: []streamableRequest{
@ -1409,10 +1439,16 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string,
if sessionID != "" {
req.Header.Set(sessionIDHeader, sessionID)
}
req.Header.Set("Content-Type", "application/json")
if s.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json, text/event-stream")
maps.Copy(req.Header, s.headers)
if req.Header.Get("Content-Type") == "" {
req.Header.Del("Content-Type")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, nil, fmt.Errorf("request failed: %v", err)
@ -2436,3 +2472,76 @@ func TestStreamableLocalhostProtection(t *testing.T) {
})
}
}
func TestStreamableOriginProtection(t *testing.T) {
server := NewServer(testImpl, nil)
tests := []struct {
name string
protection *http.CrossOriginProtection
requestOrigin string
wantStatusCode int
}{
{
name: "default protection with Origin header",
protection: nil,
requestOrigin: "https://example.com",
wantStatusCode: http.StatusForbidden,
},
{
name: "custom protection with trusted origin and same Origin",
protection: func() *http.CrossOriginProtection {
p := http.NewCrossOriginProtection()
if err := p.AddTrustedOrigin("https://example.com"); err != nil {
t.Fatal(err)
}
return p
}(),
requestOrigin: "https://example.com",
wantStatusCode: http.StatusOK,
},
{
name: "custom protection with trusted origin and different Origin",
protection: func() *http.CrossOriginProtection {
p := http.NewCrossOriginProtection()
if err := p.AddTrustedOrigin("https://example.com"); err != nil {
t.Fatal(err)
}
return p
}(),
requestOrigin: "https://malicious.com",
wantStatusCode: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &StreamableHTTPOptions{
Stateless: true, // avoid session ID requirement
CrossOriginProtection: tt.protection,
}
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts)
httpServer := httptest.NewServer(handler)
defer httpServer.Close()
reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL, reqReader)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", tt.requestOrigin)
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.wantStatusCode {
t.Errorf("Status code: got %d, want %d", got, tt.wantStatusCode)
}
})
}
}