Improve security of fleet-mcp and update README (#43007)

This commit is contained in:
Luke Heath 2026-04-09 09:37:43 -05:00 committed by GitHub
parent b24e76408f
commit 678ea81998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 33 additions and 7 deletions

View file

@ -1 +1,2 @@
fleet
fleet-mcp

View file

@ -28,6 +28,7 @@ Both **SSE** (Server-Sent Events) and **stdio** transports are supported.
|------|-------------|
| `get_endpoints` | List all hosts/endpoints enrolled in Fleet |
| `get_host` | Get full details for a single host including labels, team, and platform info |
| `get_fleets` | Get all fleets (teams) with their IDs and names |
| `get_queries` | List all saved Fleet queries |
| `get_policies` | List all policies with pass/fail host counts |
| `get_labels` | List all endpoint labels |
@ -53,7 +54,7 @@ Configure the server using environment variables or a `.env` file.
| `LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
| `FLEET_TLS_SKIP_VERIFY` | `false` | Skip TLS certificate verification. **Dev/test only — do not use in production.** |
| `FLEET_CA_FILE` | *(optional)* | Path to a PEM CA certificate for self-signed Fleet instances |
| `MCP_AUTH_TOKEN` | *(required in production)* | Bearer token MCP clients must send in the `Authorization` header. Generate with `openssl rand -hex 32`. If unset the server starts unauthenticated (with a warning) — acceptable for local dev only. |
| `MCP_AUTH_TOKEN` | *(required)* | Bearer token for authenticating MCP clients. Generate with `openssl rand -hex 32`. The server will refuse to start without it. In SSE mode, clients must include this token in the `Authorization` header on every request, and the server validates it each time. In stdio mode the token is not checked at runtime (the client launches the binary as a local subprocess) but must still be set. |
Copy the provided example to get started:
@ -136,6 +137,7 @@ Stdio mode runs the binary directly as a subprocess with no network port needed.
"env": {
"FLEET_BASE_URL": "https://your-fleet.example.com",
"FLEET_API_KEY": "YOUR_FLEET_API_KEY",
"MCP_AUTH_TOKEN": "YOUR_MCP_AUTH_TOKEN",
"LOG_LEVEL": "info"
}
}

View file

@ -1,15 +1,23 @@
package main
import "net/http"
import (
"crypto/subtle"
"net/http"
)
// bearerAuthMiddleware rejects requests whose Authorization header does not
// match "Bearer <token>", returning 401 Unauthorized.
// match "Bearer <token>", returning 401 Unauthorized. The comparison uses
// crypto/subtle.ConstantTimeCompare to prevent timing side-channel attacks.
func bearerAuthMiddleware(token string, next http.Handler) http.Handler {
expected := []byte("Bearer " + token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
got := []byte(r.Header.Get("Authorization"))
if subtle.ConstantTimeCompare(got, expected) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

Binary file not shown.

Binary file not shown.

View file

@ -69,6 +69,9 @@ func NewFleetClient(baseURL, apiKey string, tlsSkipVerify bool, caFile string) *
}
if tlsSkipVerify {
if !isLoopbackURL(baseURL) {
logrus.Errorf("FLEET_TLS_SKIP_VERIFY is set but FLEET_BASE_URL (%s) does not point at localhost — this is unsafe for non-local deployments", baseURL)
}
logrus.Warn("TLS certificate verification is disabled — do not use in production")
tlsCfg.InsecureSkipVerify = true //nolint:gosec
} else if caFile != "" {
@ -95,6 +98,18 @@ func NewFleetClient(baseURL, apiKey string, tlsSkipVerify bool, caFile string) *
}
}
// isLoopbackURL parses a URL and returns true only if the hostname is exactly
// "localhost", "127.0.0.1", or "::1". This avoids prefix-matching pitfalls
// like "localhost.evil.com".
func isLoopbackURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := u.Hostname() // strips port if present
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// HostLabel represents a label attached to a host (Fleet returns objects, not plain strings)
type HostLabel struct {
ID uint `json:"id"`

View file

@ -23,6 +23,9 @@ func main() {
if strings.TrimSpace(config.FleetAPIKey) == "" {
logrus.Fatalf("FLEET_API_KEY is required but is not set")
}
if strings.TrimSpace(config.MCPAuthToken) == "" {
logrus.Fatalf("MCP_AUTH_TOKEN is required at startup for all transports, including stdio, but is not set")
}
// Stderr is required for stdio transport — logs must not corrupt the JSON-RPC stdout stream.
logrus.SetOutput(os.Stderr)
@ -45,9 +48,6 @@ func main() {
logrus.Infof("transport: SSE — listening on :%s", config.Port)
sseServer := server.NewSSEServer(mcpServer)
var handler http.Handler = sseServer
if config.MCPAuthToken == "" {
logrus.Fatalf("MCP_AUTH_TOKEN is required for SSE transport but is not set")
}
logrus.Info("authentication enabled")
handler = bearerAuthMiddleware(config.MCPAuthToken, handler)
handler = mcpRouteGuard(handler)