Files
fleet/tools/fleet-mcp/schema.go
T
Nico d671cbef99 fix(fleet-mcp): security hardening for the MCP server (#47908)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Relates to #43544

Security review + hardening of the experimental Fleet MCP server. Issues
were reproduced against a live dev Fleet; this PR fixes the MCP-layer
ones.

| Finding | Before → After | Where |
|---|---|---|
| **A** least privilege | No signal about the token's power → startup
`/me` check **refuses any non-API-only token** (fails closed if
unreachable). API-only users can be scoped to specific endpoints/teams
and their token revoked. Who-can-do-what is documented (Fleet RBAC), not
inferred at runtime. | `main.go` `requireAPIOnlyUser`;
`fleet_integration.go` `WhoAmI` |
| **B** rate-limit XFF bypass | Per-IP keyed on spoofable
`X-Forwarded-For` → Removed rate-limiting (assuming Fleet handles this).
| N/A |
| **C** token egress / SSRF | `FLEET_BASE_URL` unchecked → This is
configured at deployment time and not per-request, so no action is
taken. | N/A |
| **D** error-body leak | Raw Fleet JSON (incl. internal `uuid`) →
trimmed, e.g. `Fleet API returned HTTP 409: Resource Already Exists`. |
`fleet_integration.go` `fleetErrMsg` |
| **E** dead code / docs / tests | Removed dead `GetFleetConfig`; README
18→19 / 16→17; added validator/role tests. | `fleet_integration.go`,
`README.md`, `*_test.go` |
| **F** writes auto-runnable | Only the advisory `destructive=true`
annotation → `fleetMCPInstructions` now tells the client to show the SQL
+ targets and confirm before running `run_live_query`. **Advisory, not a
server control.** | `mcp_server.go` |
| **G** abrupt SIGTERM shutdown | `ListenAndServe` blocked with no
signal handling → on SIGTERM the process was killed immediately (**exit
143**), resetting any in-flight connection at once. Now a signal-aware
root context (`signal.NotifyContext`) drains in-flight requests via
`http.Server.Shutdown` (10s cap), logs `shutting down`, and **exits 0**
— and the startup `/me` check + temp-query sweep + stdio loop all honor
it. Matters for Render redeploys (SIGTERM). | `main.go` |

### Key decisions
- **Rate limit: removed**: assuming Fleet server handles this.
- **`fleetMCPInstructions` / `destructive=true` are advisory** — a
prompt-injected or raw client ignores them. The enforceable controls are
the token role and `--disable_tables` flag. Added an explicit
confirm-before-run instruction for `run_live_query` to
`fleetMCPInstructions` as a cross-client complement to the
`destructiveHint=true` annotation -- the annotation only prompts on
clients that honor it, whereas the instruction reaches any client that
forwards server instructions to the model (and raw JSON-RPC callers that
have no approval UI at all). Both are advisory; the real bound on writes
remains the `FLEET_API_KEY`'s Fleet role.
- **Device-side `curl`/`carves` exfil is a Fleet/osquery capability**,
equally reachable via the UI/`fleetctl`/REST — not an MCP bug.
Comprehensive fix is agent `--disable_tables` (separate
Fleet-server/agent issue).

### Residual attack surface (re: *"no new attack vectors"*)
None of these is a vector the MCP invents beyond what Fleet already
exposes:

| Config choice | Residual vector | Mitigation |
|---|---|---|
| Non-API-only / admin token | Leak = arbitrary osquery everywhere |
API-only **required** (refuses to start otherwise); use **observer** for
read-only. As an API-only user it can also be **scoped to specific
endpoints/teams** and its **token revoked** on leak — neither possible
with a UI session token. |
| `run_live_query` via an auto-approving / prompt-injected client |
Arbitrary osquery on currently-online hosts - read/exfil-capable
(`curl`, `curl_certificate`, `file`, `carves`). No data mutation. |
Non-advisory: `FLEET_API_KEY` role - **observer-plus** needed to run
live queries. A "read-only" deployment (**observer** token) removes the
vector entirely; plus agent-side `--disable-tables` to drop exfil
tables. Advisory: `destructiveHint=true` + confirm-before-run
instruction (F) - an auto-approving client ignores these. |
| Live query at all | `curl`/`carves`/`file` device SSRF + exfil | Agent
`--disable_tables` (separate issue) — not an MCP-layer fix |
| Public SSE, no edge | Unauth flood `429`s operators (shared bucket —
fails safe) | We assume Fleet server handles rate-limiting. |

**Bottom line:** with an API-only observer/+ token (now enforced) over
stdio, `ip` mode, or SSE-behind-an-edge, the MCP adds no new attack
vector beyond Fleet's existing live-query capability; the residual
`curl`/`carves` risk is a Fleet-layer concern tracked separately.


# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Startup now verifies the Fleet API key belongs to an **API-only**
Fleet user
  * Added `/healthz` endpoint
* Hardened Fleet base URL validation to prevent token leakage to unsafe
hosts
* Improved rate limiting behavior (429 includes `Retry-After: 1`; per-IP
uses the direct client address host)
* Signal-driven graceful shutdown and stricter `MCP_AUTH_TOKEN` length
checks
* More robust schema fetching by blocking cross-host and overly long
redirect chains
* **Documentation**
* Expanded/clarified write-operation guidance and confirmation
requirements
* **Chores / Tests**
* Updated `.env` docs/ignores, added developer Makefile helpers, and
refreshed Fleet base URL tests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-30 11:30:19 -03:00

502 lines
17 KiB
Go

package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sirupsen/logrus"
)
// SchemaTable is the source-of-truth representation of an osquery/Fleet table.
// Field tags match the canonical schema JSON shape published in the Fleet
// monorepo at /schema/osquery_fleet_schema.json (also rendered at
// https://fleetdm.com/tables). MCP clients receive these structs as-is in the
// `get_osquery_schema` and `prepare_live_query` responses.
type SchemaTable struct {
Name string `json:"name"`
Platforms []string `json:"platforms"`
Description string `json:"description"`
Columns []Column `json:"columns"`
Examples string `json:"examples,omitempty"`
Notes string `json:"notes,omitempty"`
URL string `json:"url,omitempty"`
}
// Column is a single column on a SchemaTable. Type is lowercased per the
// canonical JSON ("text", "integer", "bigint", "double"). The validator and
// callers compare types case-insensitively.
type Column struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Notes string `json:"notes,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
// schemaSource records where the live schema came from on the most recent
// successful load — surfaced in the get_osquery_schema response so callers can
// tell whether the data is fresh from fleetdm.com or the embedded fallback.
//
// FetchedAt is only set on a live HTTP fetch. The embedded snapshot leaves it
// nil so a vendored copy doesn't masquerade as freshly fetched data.
type schemaSource struct {
Origin string `json:"origin"` // "live" or "embed"
URL string `json:"url"` // canonical URL fetched (or empty for embed)
FetchedAt *time.Time `json:"fetched_at,omitempty"` // set only when Origin == "live"
}
const (
// canonicalSchemaURL is the live source the schema is refreshed from.
// It points at the same JSON file the fleetdm.com/tables page is generated
// from, so the in-memory schema always reflects the documented surface.
canonicalSchemaURL = "https://raw.githubusercontent.com/fleetdm/fleet/main/schema/osquery_fleet_schema.json"
defaultRefreshInterval = 24 * time.Hour
fetchTimeout = 15 * time.Second
)
// The vendored copy next to this file is what //go:embed pulls into the
// binary as the offline fallback. Refresh from the canonical Fleet monorepo
// via `go generate ./tools/fleet-mcp/...` whenever Fleet upstream rebuilds
// the schema.
//go:generate cp ../../schema/osquery_fleet_schema.json ./osquery_fleet_schema.json
//go:embed osquery_fleet_schema.json
var embeddedSchemaJSON []byte
var (
schemaMu sync.RWMutex
schemaTables []SchemaTable
schemaIndex map[string]SchemaTable // lowercase table name → table
// schemaInfo is set atomically so concurrent readers don't need to take
// schemaMu for the cheap "where did this come from?" lookup.
schemaInfo atomic.Pointer[schemaSource]
refreshOnce sync.Once
)
// defaultCuratedTables is the curated list of "most useful for security ops"
// table names returned by `get_osquery_schema(platform=...)` when the caller
// does not request specific tables. The list intentionally stays small — full
// canonical coverage (~360 tables) is available via the `tables` parameter on
// the same tool, and via GetOsquerySchemaForTables below.
var defaultCuratedTables = []string{
// Universal
"os_version", "system_info", "uptime", "users", "logged_in_users",
"processes", "listening_ports", "interface_addresses", "interface_details",
"kernel_info", "osquery_info", "file", "hash", "certificates",
// macOS
"apps", "browser_plugins", "managed_policies", "system_extensions",
"munki_info", "battery", "disk_encryption",
// Windows
"programs", "windows_update_history", "windows_security_center",
"patches", "services", "scheduled_tasks",
"bitlocker_info", "windows_optional_features",
// Linux
"deb_packages", "rpm_packages", "apt_sources",
"kernel_modules", "systemd_units",
}
func init() {
// Always load the embedded snapshot synchronously so the rest of the
// binary can serve schema lookups immediately, even if the live fetch
// later fails or the process runs offline.
tables, err := parseSchemaJSON(embeddedSchemaJSON)
if err != nil {
panic(fmt.Sprintf("fleet-mcp: failed to parse embedded osquery schema: %v", err))
}
storeSchema(tables, schemaSource{Origin: "embed"})
}
// StartSchemaRefresh kicks off a background goroutine that fetches the
// canonical schema from canonicalSchemaURL once on entry and then on
// `interval` ticks. Safe to call exactly once; subsequent calls are no-ops.
// If interval is zero, defaultRefreshInterval is used. If the env var
// FLEET_MCP_SCHEMA_REFRESH_DISABLE is set, the function returns without
// spawning anything (useful for offline / air-gapped deployments).
func StartSchemaRefresh(interval time.Duration) {
if os.Getenv("FLEET_MCP_SCHEMA_REFRESH_DISABLE") != "" {
logrus.Info("Schema refresh disabled via FLEET_MCP_SCHEMA_REFRESH_DISABLE; using embedded snapshot only.")
return
}
if interval <= 0 {
interval = defaultRefreshInterval
if v := os.Getenv("FLEET_MCP_SCHEMA_REFRESH_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
interval = d
}
}
}
refreshOnce.Do(func() {
go func() {
// Initial fetch happens shortly after startup so it doesn't block
// the MCP server from becoming responsive on launch.
time.Sleep(2 * time.Second)
if err := RefreshSchemaNow(); err != nil {
logrus.Warnf("Initial schema refresh failed (using embedded fallback): %v", err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := RefreshSchemaNow(); err != nil {
logrus.Warnf("Periodic schema refresh failed (keeping previous data): %v", err)
}
}
}()
})
}
// RefreshSchemaNow performs a one-shot fetch from canonicalSchemaURL and
// replaces the in-memory schema on success. The previous schema is retained on
// failure — never returns the binary into a no-schema state. Exposed both for
// the background loop and for the MCP `refresh_osquery_schema` tool.
func RefreshSchemaNow() error {
body, err := fetchCanonicalSchema(canonicalSchemaURL)
if err != nil {
return err
}
tables, err := parseSchemaJSON(body)
if err != nil {
return fmt.Errorf("parse fetched schema: %w", err)
}
now := time.Now()
storeSchema(tables, schemaSource{
Origin: "live",
URL: canonicalSchemaURL,
FetchedAt: &now,
})
logrus.Infof("Refreshed osquery schema from %s (%d tables)", canonicalSchemaURL, len(tables))
return nil
}
func fetchCanonicalSchema(url string) ([]byte, error) {
client := &http.Client{
Timeout: fetchTimeout,
// Refuse cross-host redirects: the canonical URL is a fixed
// githubusercontent.com path, so any redirect to a different host is
// unexpected and could be bent toward an internal/metadata endpoint.
// raw.githubusercontent.com serves 200 directly, so this
// does not break the normal fetch.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
return fmt.Errorf("refusing cross-host redirect to %q", req.URL.Host)
}
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "fleet-mcp/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("get %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get %s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024)) // hard cap @ 16 MiB
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return body, nil
}
func parseSchemaJSON(data []byte) ([]SchemaTable, error) {
var tables []SchemaTable
if err := json.Unmarshal(data, &tables); err != nil {
return nil, fmt.Errorf("unmarshal schema: %w", err)
}
if len(tables) == 0 {
return nil, errors.New("schema payload contained zero tables")
}
return tables, nil
}
func storeSchema(tables []SchemaTable, src schemaSource) {
idx := make(map[string]SchemaTable, len(tables))
for _, t := range tables {
idx[strings.ToLower(t.Name)] = t
}
schemaMu.Lock()
schemaTables = tables
schemaIndex = idx
schemaMu.Unlock()
srcCopy := src
schemaInfo.Store(&srcCopy)
}
// SchemaSource returns metadata describing where the in-memory schema was last
// loaded from (live HTTP vs embedded snapshot) and when. Safe for concurrent
// callers; cheap (atomic load).
func SchemaSource() schemaSource {
if p := schemaInfo.Load(); p != nil {
return *p
}
return schemaSource{}
}
// GetOsquerySchema returns a curated subset of common tables filtered by
// platform. Use GetOsquerySchemaForTables for full coverage of specific tables.
// Platform values: "darwin"/"macos", "windows", "linux", "chrome"/"chromeos",
// "all" (returns the curated set unfiltered).
func GetOsquerySchema(platform string) ([]SchemaTable, error) {
p := normalizePlatform(platform)
schemaMu.RLock()
defer schemaMu.RUnlock()
if len(schemaIndex) == 0 {
return nil, errors.New("schema not loaded")
}
out := make([]SchemaTable, 0, len(defaultCuratedTables))
missing := []string{}
for _, name := range defaultCuratedTables {
t, ok := schemaIndex[strings.ToLower(name)]
if !ok {
missing = append(missing, name)
continue
}
if p == "" || p == "all" || tablePlatformMatches(t, p) {
out = append(out, t)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no curated tables found for platform %q (supported: darwin/macos, windows, linux, chrome/chromeos, all)", platform)
}
if len(missing) > 0 {
// Not fatal — the canonical schema may have renamed/dropped a table the
// curated list still references. Log so we notice.
logrus.Debugf("Curated tables missing from canonical schema: %v", missing)
}
return out, nil
}
// GetOsquerySchemaForTables returns the canonical schema for a specific set of
// table names (case-insensitive). Unknown names are reported as a single error
// alongside the known matches so the caller can still use what was found.
func GetOsquerySchemaForTables(names []string) ([]SchemaTable, error) {
schemaMu.RLock()
defer schemaMu.RUnlock()
if len(schemaIndex) == 0 {
return nil, errors.New("schema not loaded")
}
out := make([]SchemaTable, 0, len(names))
unknown := []string{}
for _, raw := range names {
n := strings.ToLower(strings.TrimSpace(raw))
if n == "" {
continue
}
if t, ok := schemaIndex[n]; ok {
out = append(out, t)
} else {
unknown = append(unknown, raw)
}
}
if len(out) == 0 && len(unknown) > 0 {
return nil, fmt.Errorf("none of the requested tables exist in canonical schema: %v", unknown)
}
if len(unknown) > 0 {
return out, fmt.Errorf("unknown tables (omitted from result): %v", unknown)
}
return out, nil
}
func tablePlatformMatches(t SchemaTable, normalizedPlatform string) bool {
if normalizedPlatform == "" || normalizedPlatform == "all" {
return true
}
for _, tp := range t.Platforms {
if normalizePlatform(tp) == normalizedPlatform {
return true
}
}
return false
}
// IsTableSupportedOnPlatform returns true if the table either supports the
// platform or is unknown to the canonical schema (we never block on unknowns —
// Fleet itself decides). Returns false only for known-incompatible pairs.
func IsTableSupportedOnPlatform(tableName, platform string) bool {
p := normalizePlatform(platform)
schemaMu.RLock()
defer schemaMu.RUnlock()
t, ok := schemaIndex[strings.ToLower(tableName)]
if !ok {
return true
}
for _, tp := range t.Platforms {
if normalizePlatform(tp) == p {
return true
}
}
return false
}
// columnTypeRe captures `<column> <op> <number>` pairs so the validator can
// flag the common bug class of comparing a TEXT column against a bare integer
// literal (e.g. `result_code = 2`). Quoted strings, NULL, and parameter
// placeholders are intentionally not captured.
var columnTypeRe = regexp.MustCompile(`(?i)\b([a-z_][a-z0-9_]*)\s*(?:=|!=|<>|>=|<=|>|<)\s*([0-9]+)\b`)
// fromJoinTableRe captures table tokens following FROM / JOIN keywords. Simpler
// and a little more robust than the previous tokenizer since it survives
// embedded newlines and parens.
var fromJoinTableRe = regexp.MustCompile(`(?i)\b(?:FROM|JOIN)\s+(\(?\s*)([a-z_][a-z0-9_]*)`)
// ValidateSQLForPlatforms is a best-effort pre-flight check for live queries.
// It enforces two contracts:
// 1. Every referenced table must support every supplied platform.
// 2. Bare integer literals in WHERE clauses must not be compared against TEXT
// columns (the #1 cause of silent zero-row queries — see the
// windows_update_history.result_code regression).
//
// Either failure produces an actionable error pointing the caller back at
// get_osquery_schema. Quoted-string-vs-integer comparisons are NOT flagged
// because osquery / SQLite type-coerces them and they are not a real bug
// source in our query corpus.
func ValidateSQLForPlatforms(sql string, platforms []string) error {
if sql == "" {
return nil
}
// Resolve the tables referenced in the SQL. Used by both checks.
usedTables := extractTables(sql)
// (1) Platform compatibility.
if len(platforms) > 0 {
normalized := make([]string, 0, len(platforms))
for _, p := range platforms {
if np := normalizePlatform(p); np != "" {
normalized = append(normalized, np)
}
}
for _, table := range usedTables {
for _, platform := range normalized {
if !IsTableSupportedOnPlatform(table, platform) {
return fmt.Errorf(
"SQL validation error: table %q is not supported on platform %q. "+
"Call get_osquery_schema(platform=%q) to see supported tables, "+
"or get_osquery_schema(tables=%q) to verify the table name is correct. "+
"Common cause: assuming a table exists on this platform without checking the schema.",
table, platform, platform, table,
)
}
}
}
}
// (2) Defense-in-depth: TEXT column compared to bare integer literal.
// Only fires when the column belongs to a known table and every matching
// table agrees the column is TEXT — avoids false positives from same-named
// columns in JOINed tables with diverging types.
if len(usedTables) == 0 {
return nil
}
schemaMu.RLock()
defer schemaMu.RUnlock()
for _, m := range columnTypeRe.FindAllStringSubmatch(sql, -1) {
colName := m[1]
lit := m[2]
if isSQLKeyword(colName) {
continue
}
// Skip unambiguous integer-domain identifiers like LIMIT/OFFSET trailing
// numbers — those don't take a column on the LHS but the regex requires
// a leading word, so SQL keywords are filtered above.
var (
matchedKnown bool
allText = true
)
for _, table := range usedTables {
canon, ok := schemaIndex[strings.ToLower(table)]
if !ok {
continue
}
for _, c := range canon.Columns {
if !strings.EqualFold(c.Name, colName) {
continue
}
matchedKnown = true
if !strings.EqualFold(c.Type, "text") {
allText = false
}
}
}
if matchedKnown && allText {
// Don't flag literal 0 / 1 against TEXT (some queries use them as
// boolean flags via SQLite coercion). The original bug used 2/4 etc.
if v, err := strconv.Atoi(lit); err == nil && (v == 0 || v == 1) {
continue
}
return fmt.Errorf(
"SQL validation error: column %q is TEXT but compared against bare integer literal %s. "+
"TEXT columns require quoted string literals (e.g. %s = 'Succeeded'). "+
"Call get_osquery_schema(tables=%q) to confirm column type and valid enum values. "+
"This is the most common cause of silent zero-row queries on Fleet.",
colName, lit, colName, strings.Join(usedTables, ","),
)
}
}
return nil
}
func extractTables(sql string) []string {
out := []string{}
seen := map[string]struct{}{}
for _, m := range fromJoinTableRe.FindAllStringSubmatch(sql, -1) {
name := strings.TrimSpace(m[2])
key := strings.ToLower(name)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, name)
}
return out
}
// sqlKeywords lists tokens the table/column extractor must not treat as
// identifiers when they appear right of FROM/JOIN or left of `=`.
var sqlKeywords = map[string]struct{}{
"select": {}, "from": {}, "where": {}, "and": {}, "or": {}, "not": {},
"in": {}, "is": {}, "null": {}, "like": {}, "between": {},
"join": {}, "inner": {}, "outer": {}, "left": {}, "right": {},
"on": {}, "as": {}, "by": {}, "group": {}, "order": {},
"limit": {}, "offset": {}, "asc": {}, "desc": {},
"case": {}, "when": {}, "then": {}, "else": {}, "end": {},
"distinct": {}, "all": {}, "union": {}, "exists": {},
"true": {}, "false": {},
}
func isSQLKeyword(tok string) bool {
_, ok := sqlKeywords[strings.ToLower(tok)]
return ok
}