mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Improve security of fleet-mcp and update README (#43007)
This commit is contained in:
parent
b24e76408f
commit
678ea81998
7 changed files with 33 additions and 7 deletions
1
tools/fleet-mcp/.gitignore
vendored
1
tools/fleet-mcp/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
fleet
|
||||
fleet-mcp
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue