mirror of
https://github.com/alibaba/higress.git
synced 2026-07-24 05:10:34 +08:00
feat: require HTTP Basic auth for higress-ops MCP server (#4139)
Signed-off-by: zty98751 <zty98751@alibaba-inc.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
45
plugins/golang-filter/mcp-session/common/basicauth.go
Normal file
45
plugins/golang-filter/mcp-session/common/basicauth.go
Normal 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
|
||||
}
|
||||
39
plugins/golang-filter/mcp-session/common/basicauth_test.go
Normal file
39
plugins/golang-filter/mcp-session/common/basicauth_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user