feat: require HTTP Basic auth for higress-ops MCP server (#4139)

Signed-off-by: zty98751 <zty98751@alibaba-inc.com>
This commit is contained in:
澄潭
2026-07-14 17:51:28 +08:00
committed by GitHub
parent 154782660c
commit e169ccbe5f
5 changed files with 134 additions and 2 deletions

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
})
}
}