From e169ccbe5f11967f2f171068d8983b6fcaecee3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Tue, 14 Jul 2026 17:51:28 +0800 Subject: [PATCH] feat: require HTTP Basic auth for higress-ops MCP server (#4139) Signed-off-by: zty98751 --- plugins/golang-filter/mcp-server/config.go | 16 ++++++- plugins/golang-filter/mcp-server/filter.go | 13 ++++++ .../servers/higress/higress-ops/server.go | 23 ++++++++++ .../mcp-session/common/basicauth.go | 45 +++++++++++++++++++ .../mcp-session/common/basicauth_test.go | 39 ++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 plugins/golang-filter/mcp-session/common/basicauth.go create mode 100644 plugins/golang-filter/mcp-session/common/basicauth_test.go diff --git a/plugins/golang-filter/mcp-server/config.go b/plugins/golang-filter/mcp-server/config.go index 521ba2dc9..c2bb7fb92 100644 --- a/plugins/golang-filter/mcp-server/config.go +++ b/plugins/golang-filter/mcp-server/config.go @@ -24,6 +24,10 @@ const ( type SSEServerWrapper struct { BaseServer *common.SSEServer HostMatchers []common.HostMatcher // Pre-parsed host matchers for efficient matching + // AuthUsername/AuthPassword hold the HTTP Basic credentials required to + // reach this server. An empty AuthUsername means authentication is disabled. + AuthUsername string + AuthPassword string } type config struct { @@ -110,12 +114,20 @@ func (p *Parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int return nil, fmt.Errorf("failed to initialize MCP Server: %w", err) } - conf.servers = append(conf.servers, &SSEServerWrapper{ + wrapper := &SSEServerWrapper{ BaseServer: common.NewSSEServer(serverInstance, common.WithSSEEndpoint(fmt.Sprintf("%s%s", serverPath, mcp_session.GlobalSSEPathSuffix)), common.WithMessageEndpoint(serverPath)), HostMatchers: hostMatchers, - }) + } + + // Servers that require HTTP Basic auth expose their credentials via the + // BasicAuthProvider interface; the filter enforces them per request. + if authProvider, ok := server.(common.BasicAuthProvider); ok { + wrapper.AuthUsername, wrapper.AuthPassword = authProvider.GetBasicAuthCredentials() + } + + conf.servers = append(conf.servers, wrapper) api.LogDebug(fmt.Sprintf("Registered MCP Server: %s", serverType)) } diff --git a/plugins/golang-filter/mcp-server/filter.go b/plugins/golang-filter/mcp-server/filter.go index 5d325f556..8067a300e 100644 --- a/plugins/golang-filter/mcp-server/filter.go +++ b/plugins/golang-filter/mcp-server/filter.go @@ -31,6 +31,19 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api. for _, server := range f.config.servers { if common.MatchDomainWithMatchers(f.host, server.HostMatchers) && strings.HasPrefix(f.path, server.BaseServer.GetMessageEndpoint()) { + // Enforce HTTP Basic auth for servers that require it, before any + // further processing of the request. + if server.AuthUsername != "" { + authHeader, _ := header.Get("authorization") + if !common.CheckBasicAuth(authHeader, server.AuthUsername, server.AuthPassword) { + f.callbacks.DecoderFilterCallbacks().SendLocalReply( + http.StatusUnauthorized, + "Unauthorized", + map[string][]string{"WWW-Authenticate": {`Basic realm="MCP Server"`}}, + 0, "") + return api.LocalReply + } + } if url.Method != http.MethodPost { f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusMethodNotAllowed, "Method not allowed", nil, 0, "") return api.LocalReply diff --git a/plugins/golang-filter/mcp-server/servers/higress/higress-ops/server.go b/plugins/golang-filter/mcp-server/servers/higress/higress-ops/server.go index 28cf0cb38..953da111e 100644 --- a/plugins/golang-filter/mcp-server/servers/higress/higress-ops/server.go +++ b/plugins/golang-filter/mcp-server/servers/higress/higress-ops/server.go @@ -19,9 +19,18 @@ type HigressOpsConfig struct { envoyAdminURL string namespace string istiodToken string + username string + password string description string } +// GetBasicAuthCredentials implements common.BasicAuthProvider. The higress-ops +// server exposes Istio/Envoy debug interfaces, so it always requires callers to +// present HTTP Basic credentials. +func (c *HigressOpsConfig) GetBasicAuthCredentials() (string, string) { + return c.username, c.password +} + func (c *HigressOpsConfig) ParseConfig(config map[string]interface{}) error { istiodURL, ok := config["istiodURL"].(string) if !ok { @@ -41,6 +50,20 @@ func (c *HigressOpsConfig) ParseConfig(config map[string]interface{}) error { c.namespace = "higress-system" } + // Basic auth credentials are mandatory: the higress-ops server exposes + // Istio/Envoy debug interfaces and must not be reachable unauthenticated. + username, ok := config["username"].(string) + if !ok || username == "" { + return errors.New("missing username: higress-ops requires basic auth credentials (username and password)") + } + c.username = username + + password, ok := config["password"].(string) + if !ok || password == "" { + return errors.New("missing password: higress-ops requires basic auth credentials (username and password)") + } + c.password = password + // Optional: Istiod authentication token (required for cross-pod access) if istiodToken, ok := config["istiodToken"].(string); ok { c.istiodToken = istiodToken diff --git a/plugins/golang-filter/mcp-session/common/basicauth.go b/plugins/golang-filter/mcp-session/common/basicauth.go new file mode 100644 index 000000000..1c6caba0a --- /dev/null +++ b/plugins/golang-filter/mcp-session/common/basicauth.go @@ -0,0 +1,45 @@ +package common + +import ( + "crypto/subtle" + "encoding/base64" + "strings" +) + +// BasicAuthProvider is implemented by server configs that require HTTP Basic +// authentication. When a server exposes non-empty credentials, the MCP server +// filter enforces Basic auth on every request routed to that server. +type BasicAuthProvider interface { + // GetBasicAuthCredentials returns the username and password required to + // access the server. An empty username means authentication is disabled. + GetBasicAuthCredentials() (username string, password string) +} + +// CheckBasicAuth reports whether the given Authorization header value carries +// HTTP Basic credentials matching the expected username and password. The +// comparison is constant-time to avoid leaking credential length or content +// through timing. It always returns false when the expected username is empty. +func CheckBasicAuth(authHeader, expectedUsername, expectedPassword string) bool { + if expectedUsername == "" { + return false + } + + const prefix = "Basic " + if len(authHeader) < len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) { + return false + } + + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(authHeader[len(prefix):])) + if err != nil { + return false + } + + username, password, ok := strings.Cut(string(decoded), ":") + if !ok { + return false + } + + userMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 + passMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 + return userMatch && passMatch +} diff --git a/plugins/golang-filter/mcp-session/common/basicauth_test.go b/plugins/golang-filter/mcp-session/common/basicauth_test.go new file mode 100644 index 000000000..c5d3565f7 --- /dev/null +++ b/plugins/golang-filter/mcp-session/common/basicauth_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "encoding/base64" + "testing" +) + +func basicHeader(user, pass string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +func TestCheckBasicAuth(t *testing.T) { + tests := []struct { + name string + authHeader string + wantUser string + wantPass string + want bool + }{ + {"valid", basicHeader("admin", "s3cret"), "admin", "s3cret", true}, + {"wrong password", basicHeader("admin", "nope"), "admin", "s3cret", false}, + {"wrong username", basicHeader("root", "s3cret"), "admin", "s3cret", false}, + {"empty expected username disables auth", basicHeader("admin", "s3cret"), "", "s3cret", false}, + {"missing header", "", "admin", "s3cret", false}, + {"non-basic scheme", "Bearer sometoken", "admin", "s3cret", false}, + {"malformed base64", "Basic !!!notbase64!!!", "admin", "s3cret", false}, + {"missing colon", "Basic " + base64.StdEncoding.EncodeToString([]byte("adminonly")), "admin", "s3cret", false}, + {"case-insensitive scheme", "basic " + base64.StdEncoding.EncodeToString([]byte("admin:s3cret")), "admin", "s3cret", true}, + {"password containing colon", basicHeader("admin", "a:b:c"), "admin", "a:b:c", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CheckBasicAuth(tt.authHeader, tt.wantUser, tt.wantPass); got != tt.want { + t.Fatalf("CheckBasicAuth(%q, %q, %q) = %v, want %v", tt.authHeader, tt.wantUser, tt.wantPass, got, tt.want) + } + }) + } +}