Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ type AuthorizationCodeHandlerConfig struct {
// such as SSRF protections, see
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
// If not provided, http.DefaultClient will be used.
// Unless Client sets CheckRedirect, the registration and token requests
// follow a redirect only within the origin of the request and only if it
// keeps the method.
Client *http.Client

// NewTokenSource is an optional function that can be set to construct the
Expand Down Expand Up @@ -736,7 +739,8 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
oauth2.VerifierOption(authResult.usedCodeVerifier),
oauth2.SetAuthURLParam("resource", resourceURL),
}
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
client := util.RestrictRedirects(h.config.Client)
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, client)
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
Expand All @@ -750,7 +754,7 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
// once that request (or the connect operation that triggered authorization)
// completes. Use a background context that still carries the configured HTTP
// client so refreshes keep working for the life of the token source.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
var ts oauth2.TokenSource
if h.config.NewTokenSource == nil {
if token.RefreshToken == "" {
Expand Down
56 changes: 56 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/url"
"slices"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -1814,6 +1815,61 @@ func TestNewTokenSource(t *testing.T) {
}
}

func TestExchangeAuthorizationCode_CrossOriginRedirect(t *testing.T) {
var targetHits atomic.Int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits.Add(1)
}))
defer target.Close()
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("ParseForm: %v", err)
}
if r.Form.Get("grant_type") == "refresh_token" || r.Form.Get("code") == "redirect" {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
return
}
// expires_in is below oauth2's expiry delta, so the first Token call refreshes.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token": "a", "token_type": "bearer", "refresh_token": "r", "expires_in": 1}`))
}))
defer tokenServer.Close()

handler, err := NewAuthorizationCodeHandler(validConfig())
if err != nil {
t.Fatal(err)
}
cfg := &oauth2.Config{
ClientID: "test_client",
Endpoint: oauth2.Endpoint{TokenURL: tokenServer.URL, AuthStyle: oauth2.AuthStyleInParams},
}
exchange := func(code string) error {
return handler.exchangeAuthorizationCode(t.Context(), cfg, &authResult{
AuthorizationResult: &AuthorizationResult{Code: code},
usedCodeVerifier: "verifier",
}, "https://example.com/resource")
}

if err := exchange("redirect"); err == nil || !strings.Contains(err.Error(), "outside the origin") {
t.Errorf("code exchange error = %v, want redirect not followed", err)
}

if err := exchange("ok"); err != nil {
t.Fatalf("code exchange failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if _, err := ts.Token(); err == nil || !strings.Contains(err.Error(), "outside the origin") {
t.Errorf("token refresh error = %v, want redirect not followed", err)
}

if n := targetHits.Load(); n != 0 {
t.Errorf("redirect target got %d requests, want 0", n)
}
}

func TestInitialTokenSource(t *testing.T) {
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost:12345/callback",
Expand Down
8 changes: 6 additions & 2 deletions auth/extauth/client_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
Expand All @@ -30,6 +31,8 @@ type ClientCredentialsHandlerConfig struct {

// HTTPClient is an optional HTTP client for customization.
// If nil, http.DefaultClient is used.
// Unless HTTPClient sets CheckRedirect, token requests follow a redirect
// only within the origin of the request and only if it keeps the method.
// OPTIONAL.
HTTPClient *http.Client
}
Expand Down Expand Up @@ -163,7 +166,8 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
}

ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
client := util.RestrictRedirects(httpClient)
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, client)
tok, err := cfg.Token(ctxWithClient)
if err != nil {
h.mu.Lock()
Expand All @@ -177,7 +181,7 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
// ctx would make every request after the first token expires fail with
// "context canceled". Use a background context that still carries the HTTP
// client, as AuthorizationCodeHandler does.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
h.mu.Lock()
defer h.mu.Unlock()
h.tokenSource = oauth2.ReuseTokenSource(tok, cfg.TokenSource(refreshCtx))
Expand Down
41 changes: 41 additions & 0 deletions auth/extauth/client_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ package extauth

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -500,6 +502,45 @@ func TestClientCredentialsHandler_ConcurrentAuthorize(t *testing.T) {
wg.Wait()
}

func TestClientCredentialsHandler_TokenRedirectToOtherOrigin(t *testing.T) {
var targetHits atomic.Int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits.Add(1)
}))
t.Cleanup(target.Close)

// Without protected resource metadata, the resource server is also its
// own authorization server.
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
issuer := "http://" + r.Host
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"issuer": %q, "token_endpoint": %q, "code_challenge_methods_supported": ["S256"]}`, issuer, issuer+"/token")
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", server.URL+"/resource", nil)
if err := handler.Authorize(t.Context(), req, resp); err == nil || !strings.Contains(err.Error(), "outside the origin") {
t.Errorf("Authorize() error = %v, want redirect not followed", err)
}
if n := targetHits.Load(); n != 0 {
t.Errorf("redirect target got %d requests, want 0", n)
}
}

func TestSelectTokenAuthMethod(t *testing.T) {
tests := []struct {
name string
Expand Down
8 changes: 4 additions & 4 deletions auth/extauth/enterprise_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"sync"

"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
Expand Down Expand Up @@ -75,6 +76,8 @@ type EnterpriseHandlerConfig struct {

// HTTPClient is an optional HTTP client for customization.
// If nil, http.DefaultClient is used.
// Unless HTTPClient sets CheckRedirect, token requests follow a redirect
// only within the origin of the request and only if it keeps the method.
// OPTIONAL.
HTTPClient *http.Client
}
Expand Down Expand Up @@ -243,10 +246,7 @@ func exchangeJWTBearer(
cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret
}

if httpClient == nil {
httpClient = http.DefaultClient
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, util.RestrictRedirects(httpClient))

token, err := cfg.Exchange(
ctxWithClient,
Expand Down
9 changes: 4 additions & 5 deletions auth/extauth/oidc_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"strings"

"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
Expand Down Expand Up @@ -49,6 +50,8 @@ type OIDCLoginConfig struct {
LoginHint string
// HTTPClient is the HTTP client for making requests.
// If nil, http.DefaultClient is used.
// Unless HTTPClient sets CheckRedirect, token requests follow a redirect
// only within the origin of the request and only if it keeps the method.
// OPTIONAL.
HTTPClient *http.Client
}
Expand Down Expand Up @@ -196,11 +199,7 @@ func completeOIDCLogin(
return nil, fmt.Errorf("codeVerifier is required")
}

httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, util.RestrictRedirects(config.HTTPClient))

oauth2Token, err := oauth2Config.Exchange(
ctxWithClient,
Expand Down
9 changes: 9 additions & 0 deletions docs/mcpgodebug.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ Options listed below were added and will be removed in the 1.11.0 version of the
fails, restoring the previous behavior. By default, the failure is returned
only to the affected call.

- `followallredirects` added. If set to `1`, the requests of
`StreamableClientTransport` and `SSEClientTransport`, and the OAuth token and
dynamic client registration requests, follow redirects as before: to any
origin, and changing the method as `net/http` does. The default behavior was
changed so that, unless the `http.Client` in use sets `CheckRedirect`, a
redirect is followed only if it stays within the origin of the request (an
upgrade from `http` to `https` on the same host with default ports is
allowed) and keeps the method.

### 1.8.0

Options listed below were added and will be removed in the 1.9.0 version of the SDK.
Expand Down
9 changes: 9 additions & 0 deletions internal/docs/mcpgodebug.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ Options listed below were added and will be removed in the 1.11.0 version of the
fails, restoring the previous behavior. By default, the failure is returned
only to the affected call.

- `followallredirects` added. If set to `1`, the requests of
`StreamableClientTransport` and `SSEClientTransport`, and the OAuth token and
dynamic client registration requests, follow redirects as before: to any
origin, and changing the method as `net/http` does. The default behavior was
changed so that, unless the `http.Client` in use sets `CheckRedirect`, a
redirect is followed only if it stays within the origin of the request (an
upgrade from `http` to `https` on the same host with default ports is
allowed) and keeps the method.

### 1.8.0

Options listed below were added and will be removed in the 1.9.0 version of the SDK.
Expand Down
93 changes: 93 additions & 0 deletions internal/util/redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2026 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 (
"fmt"
"net/http"
"net/url"
"strings"

"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
)

// followallredirects is a compatibility parameter that restores the previous
// behavior of following every redirect that net/http follows by default.
// See the documentation for the mcpgodebug package for instructions how to enable it.
// The option will be removed in the 1.11.0 version of the SDK.
//
// Mutable for testing.
var followallredirects = mcpgodebug.Value("followallredirects")

// maxRedirects is the limit that net/http applies when CheckRedirect is nil.
const maxRedirects = 10

// RestrictRedirects returns a copy of c, or of [http.DefaultClient] if c is
// nil, that stops after 10 requests, as net/http does by default, and follows
// only redirects that stay within the origin of the request they redirect and
// keep the method of the original request. Otherwise a server could reroute a request,
// with its headers and body, to another origin, or turn a POST into a
// bodyless GET. An upgrade from http to https on the same host, with default
// ports, stays within the origin.
//
// It returns c (or [http.DefaultClient]) unchanged if c sets its own
// CheckRedirect, which then decides alone, or if the followallredirects
// MCPGODEBUG option is set.
func RestrictRedirects(c *http.Client) *http.Client {
if c == nil {
c = http.DefaultClient
}
if c.CheckRedirect != nil || followallredirects == "1" {
return c
}
restricted := *c
restricted.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
return checkRedirectWithinOrigin(req, via)
}
return &restricted
}

func checkRedirectWithinOrigin(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if prev := via[len(via)-1].URL; !withinOrigin(prev, req.URL) {
return fmt.Errorf("redirect not followed: target is outside the origin %s://%s of the request", prev.Scheme, prev.Host)
}
if req.Method != via[0].Method {
return fmt.Errorf("redirect not followed: it changes the method from %s to %s", via[0].Method, req.Method)
}
if req.URL.User != nil && req.URL.User.String() != via[len(via)-1].URL.User.String() {
return fmt.Errorf("redirect not followed: target adds or changes userinfo")
}
return nil
}

func withinOrigin(from, to *url.URL) bool {
if !strings.EqualFold(from.Hostname(), to.Hostname()) {
return false
}
fromPort, toPort := portOrDefault(from), portOrDefault(to)
if from.Scheme == to.Scheme && fromPort == toPort {
return true
}
return from.Scheme == "http" && fromPort == "80" && to.Scheme == "https" && toPort == "443"
}

func portOrDefault(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
switch u.Scheme {
case "http":
return "80"
case "https":
return "443"
}
return ""
}
Loading
Loading