From a26326af4a8b1303e30b04bcac0df9853be488e7 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 05:00:13 -0700 Subject: [PATCH] fix(server): advertise an https resource behind TLS-terminating load balancers The protected resource metadata and the 401 challenge took their scheme from X-Forwarded-Proto, falling back to http. A load balancer that terminates TLS without setting that header made the server advertise http:///mcp, which does not match the https URL clients connect to, so they reject the metadata and OAuth never starts. Without the header, only loopback hosts are plain http now; any other host is https, the only scheme MCP clients accept for a non-loopback authorization target. X-Forwarded-Proto is honored only for http or https. --- internal/flashduty/server.go | 36 ++++++++++++++++++++++++------- internal/flashduty/server_test.go | 31 ++++++++++++++++++++------ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index aaf2001..9d3068f 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" @@ -327,18 +328,37 @@ func httpContextFunc(ctx context.Context, r *http.Request, defaultBaseURL string // requestOrigin returns the scheme and host the client used to reach this // server, honoring X-Forwarded-Proto/X-Forwarded-Host set by a reverse proxy. func requestOrigin(r *http.Request) string { - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if proto := firstHeaderValue(r, "X-Forwarded-Proto"); proto != "" { - scheme = proto - } host := r.Host if fwdHost := firstHeaderValue(r, "X-Forwarded-Host"); fwdHost != "" { host = fwdHost } - return scheme + "://" + host + return requestScheme(r, host) + "://" + host +} + +// requestScheme trusts X-Forwarded-Proto when it names http or https. Without +// it (a TLS-terminating load balancer that does not set the header), only a +// loopback host is plain http: MCP clients require https for any other +// authorization target, so the public resource is https. +func requestScheme(r *http.Request, host string) string { + switch proto := strings.ToLower(firstHeaderValue(r, "X-Forwarded-Proto")); proto { + case "http", "https": + return proto + } + if r.TLS != nil { + return "https" + } + hostname := host + if h, _, err := net.SplitHostPort(host); err == nil { + hostname = h + } + hostname = strings.Trim(hostname, "[]") + if hostname == "localhost" { + return "http" + } + if ip := net.ParseIP(hostname); ip != nil && ip.IsLoopback() { + return "http" + } + return "https" } // firstHeaderValue returns the first entry of a possibly comma-separated diff --git a/internal/flashduty/server_test.go b/internal/flashduty/server_test.go index a3899a3..45b953f 100644 --- a/internal/flashduty/server_test.go +++ b/internal/flashduty/server_test.go @@ -80,9 +80,28 @@ func TestHTTPMux_MissingCredentialChallenges(t *testing.T) { want string }{ { - name: "direct", + // A TLS-terminating load balancer that sets no X-Forwarded-Proto. + name: "no forwarded proto", path: "/mcp", - want: `Bearer resource_metadata="http://mcp.example.com/.well-known/oauth-protected-resource/mcp"`, + want: `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"`, + }, + { + name: "loopback", + path: "/mcp", + headers: map[string]string{"X-Forwarded-Host": "127.0.0.1:11310"}, + want: `Bearer resource_metadata="http://127.0.0.1:11310/.well-known/oauth-protected-resource/mcp"`, + }, + { + name: "unknown forwarded proto", + path: "/mcp", + headers: map[string]string{"X-Forwarded-Proto": "gopher"}, + want: `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"`, + }, + { + name: "forwarded http", + path: "/mcp", + headers: map[string]string{"X-Forwarded-Proto": "http", "X-Forwarded-Host": "mcp.internal"}, + want: `Bearer resource_metadata="http://mcp.internal/.well-known/oauth-protected-resource/mcp"`, }, { name: "behind proxy", @@ -93,7 +112,7 @@ func TestHTTPMux_MissingCredentialChallenges(t *testing.T) { { name: "legacy path", path: "/flashduty", - want: `Bearer resource_metadata="http://mcp.example.com/.well-known/oauth-protected-resource/flashduty"`, + want: `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/flashduty"`, }, } for _, tt := range tests { @@ -161,17 +180,17 @@ func TestHTTPMux_ProtectedResourceMetadata(t *testing.T) { { name: "path inserted", path: "/.well-known/oauth-protected-resource/mcp", - wantResource: "http://mcp.example.com/mcp", + wantResource: "https://mcp.example.com/mcp", }, { name: "root describes /mcp", path: "/.well-known/oauth-protected-resource", - wantResource: "http://mcp.example.com/mcp", + wantResource: "https://mcp.example.com/mcp", }, { name: "legacy path", path: "/.well-known/oauth-protected-resource/flashduty", - wantResource: "http://mcp.example.com/flashduty", + wantResource: "https://mcp.example.com/flashduty", }, { name: "behind proxy",