Files
fleet/server/service/osquery_header_auth.go
T
Juan Fernandez 0ef22939f4 Improve auth around osquery endpoints (#44209)
Added an optional HTTP-level pre-auth middleware (enabled using the
FLEET_OSQUERY_ALLOW_BODY_AUTH_FALLBACK server config) that validates
incoming osquery requests based on `Authorization: NodeKey <node_key>`
header.
2026-05-08 12:08:10 -04:00

174 lines
7.2 KiB
Go

package service
import (
"log/slog"
"net/http"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/osqueryauth"
"github.com/fleetdm/fleet/v4/server/fleet"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Rejection reasons for the osquery pre-auth counter.
const (
preAuthRejectMissing = "missing" // absent or wrong-scheme Authorization header
preAuthRejectInvalidToken = "invalid_token" // header scheme matched but token failed validation
)
// osqueryPreAuthRejections counts osquery pre-auth rejections by reason and
// route. Operators can alert on sustained growth of the "invalid_token" or
// "missing" buckets. Initialized at package load; package panics if the OTEL
// counter cannot be created, so the var is always non-nil at use.
var osqueryPreAuthRejections = mustNewPreAuthRejectionsCounter()
func mustNewPreAuthRejectionsCounter() metric.Int64Counter {
c, err := otel.Meter("fleet").Int64Counter(
"fleet.osquery.preauth_rejections",
metric.WithDescription("Count of osquery requests rejected by the HTTP-level header pre-auth by route and reason"),
metric.WithUnit("{request}"),
)
if err != nil {
panic(err)
}
return c
}
// preAuthRejectionAttrs returns the metric attributes for the pre-auth
// rejection counter.
func preAuthRejectionAttrs(route, reason string) metric.AddOption {
return metric.WithAttributes(
attribute.String("http.route", route),
attribute.String("reason", reason),
)
}
// osqueryHeaderAuthScheme is the canonical Authorization-header scheme used
// by osquery requests for header-based node key authentication. The format is:
//
// Authorization: NodeKey <node_key>
const osqueryHeaderAuthScheme = "NodeKey"
// osqueryHeaderPreAuth returns an HTTP middleware that authenticates osquery
// requests from the Authorization header before the request body is read.
// It is registered ONLY when osquery.allow_body_auth_fallback is false; with
// the flag at its default of true the middleware is not installed at all and
// this function never runs (the legacy body-based auth path is the sole
// authenticator). Callers must guard the .WithHTTPPreAuth(...) call with the
// flag check.
//
// When installed, the middleware enforces strict header auth:
//
// - Header present and token valid: authenticate, populate ctx so the
// endpoint-layer authenticatedHost middleware becomes a passthrough.
// - Header present and token invalid: 401 with node_invalid:true, body
// not read.
// - Header absent, malformed, or wrong scheme: 401 with node_invalid:true,
// body not read.
func osqueryHeaderPreAuth(svc fleet.Service, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
nodeKey := extractNodeKeyFromHeader(r)
if nodeKey == "" {
osqueryPreAuthRejections.Add(ctx, 1, preAuthRejectionAttrs(r.URL.Path, preAuthRejectMissing))
logger.WarnContext(ctx, "osquery request rejected: missing or malformed Authorization header",
"path", r.URL.Path, "remote_addr", r.RemoteAddr)
encodeError(ctx, newOsqueryErrorWithInvalidNode("authentication error: invalid authorization header"), w)
return
}
host, debug, err := svc.AuthenticateHost(ctx, nodeKey)
if err != nil {
osqueryPreAuthRejections.Add(ctx, 1, preAuthRejectionAttrs(r.URL.Path, preAuthRejectInvalidToken))
logger.WarnContext(ctx, "osquery request rejected: invalid Authorization header token",
"path", r.URL.Path, "remote_addr", r.RemoteAddr, "err", err)
encodeError(ctx, newOsqueryErrorWithInvalidNode("authentication error: invalid authorization header"), w)
return
}
// Populate the ctx fields that work at this stage (plain
// context.WithValue). Side effects that need the per-request
// logging and authz contexts (SetAuthnMethod, instrumentHostLogger,
// debug-mode request logging) are applied in the endpoint-layer
// authenticatedHost passthrough after kithttp.ServerBefore runs.
ctx = hostctx.NewContext(ctx, host)
ctx = ctxerr.AddErrorContextProvider(ctx, &hostctx.HostAttributeProvider{Host: host})
ctx = osqueryauth.NewPreAuthedContext(ctx)
if debug {
ctx = osqueryauth.NewDebugContext(ctx)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// extractNodeKeyFromHeader returns the node key parsed from the
// "Authorization: NodeKey <token>" header, or "" if the header is absent.
func extractNodeKeyFromHeader(r *http.Request) string {
authz := r.Header.Get("Authorization")
if authz == "" {
return ""
}
scheme, token, ok := strings.Cut(authz, " ")
if !ok || !strings.EqualFold(scheme, osqueryHeaderAuthScheme) {
return ""
}
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, " \t\r\n") {
return ""
}
return token
}
// osqueryCarveBlockHeaderPreAuth returns an HTTP middleware for
// /api/osquery/carve/block. Like osqueryHeaderPreAuth, it is registered ONLY
// when osquery.allow_body_auth_fallback is false. With the flag at its
// default of true this middleware is not installed and /carve/block falls
// back entirely to its existing byte-by-byte streaming-parse auth (session_id
// + request_id verified against the carve store).
//
// When installed, the streaming parser still runs after pre-auth succeeds, and
// CarveBlock additionally verifies that the carve's HostId matches the
// authenticated host.
//
// - Header valid NodeKey + valid token: stash the authenticated host in
// ctx; the streaming parser still runs, and CarveBlock verifies
// ownership.
// - Header valid NodeKey + invalid token: 401, body not read.
// - Header absent or wrong scheme: 401, body not read.
func osqueryCarveBlockHeaderPreAuth(svc fleet.Service, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
nodeKey := extractNodeKeyFromHeader(r)
if nodeKey == "" {
osqueryPreAuthRejections.Add(ctx, 1, preAuthRejectionAttrs(r.URL.Path, preAuthRejectMissing))
logger.WarnContext(ctx, "osquery carve/block rejected: missing or malformed Authorization header",
"path", r.URL.Path, "remote_addr", r.RemoteAddr)
encodeError(ctx, newOsqueryErrorWithInvalidNode("authentication error: invalid authorization header"), w)
return
}
host, _, err := svc.AuthenticateHost(ctx, nodeKey)
if err != nil {
osqueryPreAuthRejections.Add(ctx, 1, preAuthRejectionAttrs(r.URL.Path, preAuthRejectInvalidToken))
logger.WarnContext(ctx, "osquery carve/block rejected: invalid Authorization header token",
"path", r.URL.Path, "remote_addr", r.RemoteAddr, "err", err)
encodeError(ctx, newOsqueryErrorWithInvalidNode("authentication error: invalid authorization header"), w)
return
}
// Stash the host so carveBlockEndpoint can enforce the
// carve-ownership check.
ctx = hostctx.NewContext(ctx, host)
ctx = ctxerr.AddErrorContextProvider(ctx, &hostctx.HostAttributeProvider{Host: host})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}