diff --git a/docs/Configuration/fleet-server-configuration.md b/docs/Configuration/fleet-server-configuration.md index b60d110bff..3daef2a061 100644 --- a/docs/Configuration/fleet-server-configuration.md +++ b/docs/Configuration/fleet-server-configuration.md @@ -1316,11 +1316,11 @@ The minimum time difference between the software's "last opened at" timestamp re ### osquery_max_log_write_body_size -> `osquery_max_log_write_body_size` config value is deprecated as of Fleet 4.84. It is maintained for backwards compatibility. Please use the new `server_endpoint_request_size_overrides` for more granular control. +Maximum HTTP request body size accepted by `/api/osquery/log`. Increase this if osquery agents are submitting log batches that exceed the default limit. Accepts a byte size with a unit suffix (e.g. `10MiB`, `500KiB`). A value of `0` uses the built-in default (10MiB). -Maximum HTTP request body size accepted by the `osquery/log` endpoint. Increase this if osquery agents are submitting log batches that exceed the default limit. Accepts a byte size with a unit suffix (e.g. `10MiB`, `500KiB`). A value of `0` uses the built-in default. Values smaller than the server-wide minimum request body size are silently raised to that minimum. +This setting only applies in legacy body-auth mode (`osquery_allow_body_auth_fallback: true`). In header-auth mode (`false`) the route is not subject to any body size limit and this value is ignored. -- Default value: `10MiB` +- Default value: `0` (use built-in default of 10MiB) - Environment variable: `FLEET_OSQUERY_MAX_LOG_WRITE_BODY_SIZE` - Config file format: ```yaml @@ -1330,11 +1330,11 @@ Maximum HTTP request body size accepted by the `osquery/log` endpoint. Increase ### osquery_max_distributed_write_body_size -> `osquery_max_distributed_write_body_size` config value is deprecated as of Fleet 4.84. It is maintained for backwards compatibility. Please use the new `server_endpoint_request_size_overrides` for more granular control. +Maximum HTTP request body size accepted by `/api/osquery/distributed/write`. Increase this if osquery agents are submitting distributed query results that exceed the default limit. Accepts a byte size with a unit suffix (e.g. `10MiB`, `500KiB`). A value of `0` uses the built-in default (5MiB). -Maximum HTTP request body size accepted by the `osquery/distributed/write` endpoint. Increase this if osquery agents are submitting distributed query results that exceed the default limit. Accepts a byte size with a unit suffix (e.g. `10MiB`, `500KiB`). A value of `0` uses the built-in default. Values smaller than the server-wide minimum request body size are silently raised to that minimum. +This setting only applies in legacy body-auth mode (`osquery_allow_body_auth_fallback: true`). In header-auth mode (`false`) the route is not subject to any body size limit and this value is ignored. -- Default value: `5MiB` +- Default value: `0` (use built-in default of 5MiB) - Environment variable: `FLEET_OSQUERY_MAX_DISTRIBUTED_WRITE_BODY_SIZE` - Config file format: ```yaml @@ -1342,6 +1342,22 @@ Maximum HTTP request body size accepted by the `osquery/distributed/write` endpo max_distributed_write_body_size: 10MiB ``` +### osquery_allow_body_auth_fallback + +Selects how osquery requests are authenticated. + +When `true` (default), the `Authorization: NodeKey` header is ignored entirely and only body-based `node_key` auth is used. + +When `false`, the `Authorization: NodeKey` header is required and the body's `node_key` field is not consulted. The HTTP-level pre-auth middleware rejects requests with absent or invalid headers BEFORE the request body is read. On `/api/osquery/carve/block` the same pre-auth additionally enforces that the carve's `host_id` matches the authenticated host. + +- Default value: `true` +- Environment variable: `FLEET_OSQUERY_ALLOW_BODY_AUTH_FALLBACK` +- Config file format: + ```yaml + osquery: + allow_body_auth_fallback: false + ``` + ## External activity audit logging > Available in Fleet Premium. Activity information is available for all Fleet Free and Fleet Premium instances using the [Activities API](https://fleetdm.com/docs/using-fleet/rest-api#activities). diff --git a/server/config/config.go b/server/config/config.go index 37d87a7861..428969eee3 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -224,12 +224,27 @@ type OsqueryConfig struct { AsyncHostRedisScanKeysCount int `yaml:"async_host_redis_scan_keys_count"` MinSoftwareLastOpenedAtDiff time.Duration `yaml:"min_software_last_opened_at_diff"` - // MaxLogWriteBodySize overrides the default body size limit for the - // osquery/log endpoint. A value of 0 means use the built-in default. + // MaxLogWriteBodySize overrides the default request body size limit + // for the /api/osquery/log endpoint. A value of 0 means use the + // built-in default (DefaultMaxOsqueryLogWriteSize). This setting + // only takes effect when allow_body_auth_fallback is true (legacy + // body-auth mode). When allow_body_auth_fallback is false (header- + // auth mode) the route is not subject to any body size limit. MaxLogWriteBodySize int64 `yaml:"max_log_write_body_size"` - // MaxDistributedWriteBodySize overrides the default body size limit for the - // osquery/distributed/write endpoint. A value of 0 means use the built-in default. + // MaxDistributedWriteBodySize is the equivalent of MaxLogWriteBodySize + // for /api/osquery/distributed/write. MaxDistributedWriteBodySize int64 `yaml:"max_distributed_write_body_size"` + + // AllowBodyAuthFallback selects which authentication scheme is in + // effect for host-authenticated osquery requests. + // + // - true (default): the Authorization: NodeKey header is ignored + // entirely. The node_key extracted from the JSON body + // is the sole authenticator. + // - false: the Authorization: NodeKey header is required and the + // body's node_key field is ignored. Pre-auth rejects + // absent/invalid headers BEFORE the body is read. + AllowBodyAuthFallback bool `yaml:"allow_body_auth_fallback"` } // AsyncTaskName is the type of names that identify tasks supporting @@ -1329,9 +1344,11 @@ func (man Manager) addConfigs() { man.addConfigDuration("osquery.min_software_last_opened_at_diff", 2*time.Minute, "Minimum time difference of the software's last opened timestamp (compared to the last one saved) to trigger an update to the database") man.addConfigByteSize("osquery.max_log_write_body_size", "0", - "Maximum body size for the osquery/log endpoint (e.g. 10MiB, 500KB). 0 means use the built-in default (10MiB). Values below the server minimum request body size are raised to that minimum.") + "Maximum body size for the osquery/log endpoint (e.g. 10MiB, 500KB). 0 means use the built-in default (10MiB). Only applied when osquery.allow_body_auth_fallback is true. In header-auth mode (false) the route is not subject to any body size limit; this value is ignored.") man.addConfigByteSize("osquery.max_distributed_write_body_size", "0", - "Maximum body size for the osquery/distributed/write endpoint (e.g. 10MiB, 500KB). 0 means use the built-in default (5MiB). Values below the server minimum request body size are raised to that minimum.") + "Maximum body size for the osquery/distributed/write endpoint (e.g. 10MiB, 500KB). 0 means use the built-in default (5MiB). Only applied when osquery.allow_body_auth_fallback is true. In header-auth mode (false) the route is not subject to any body size limit; this value is ignored.") + man.addConfigBool("osquery.allow_body_auth_fallback", true, + "Selects how host-authenticated osquery requests are authenticated. When true (default), only body-based node_key is used for authentication. When false, the nodey_key header is required for authentication and the body's node_key is ignored; pre-auth rejects absent/invalid headers before the body is read.") // Activities man.addConfigBool("activity.enable_audit_log", false, @@ -1785,6 +1802,7 @@ func (man Manager) LoadConfig() FleetConfig { MinSoftwareLastOpenedAtDiff: man.getConfigDuration("osquery.min_software_last_opened_at_diff"), MaxLogWriteBodySize: man.getConfigByteSize("osquery.max_log_write_body_size"), MaxDistributedWriteBodySize: man.getConfigByteSize("osquery.max_distributed_write_body_size"), + AllowBodyAuthFallback: man.getConfigBool("osquery.allow_body_auth_fallback"), }, Activity: ActivityConfig{ EnableAuditLog: man.getConfigBool("activity.enable_audit_log"), @@ -2331,15 +2349,16 @@ func TestConfig() FleetConfig { Duration: 24 * 5 * time.Hour, }, Osquery: OsqueryConfig{ - NodeKeySize: 24, - HostIdentifier: "instance", - EnrollCooldown: 42 * time.Minute, - StatusLogPlugin: "filesystem", - ResultLogPlugin: "filesystem", - LabelUpdateInterval: 1 * time.Hour, - PolicyUpdateInterval: 1 * time.Hour, - DetailUpdateInterval: 1 * time.Hour, - MaxJitterPercent: 0, + NodeKeySize: 24, + HostIdentifier: "instance", + EnrollCooldown: 42 * time.Minute, + StatusLogPlugin: "filesystem", + ResultLogPlugin: "filesystem", + LabelUpdateInterval: 1 * time.Hour, + PolicyUpdateInterval: 1 * time.Hour, + DetailUpdateInterval: 1 * time.Hour, + MaxJitterPercent: 0, + AllowBodyAuthFallback: true, }, Activity: ActivityConfig{ EnableAuditLog: true, diff --git a/server/contexts/osqueryauth/osqueryauth.go b/server/contexts/osqueryauth/osqueryauth.go new file mode 100644 index 0000000000..d42bf933fe --- /dev/null +++ b/server/contexts/osqueryauth/osqueryauth.go @@ -0,0 +1,47 @@ +// Package osqueryauth provides a context marker indicating that an osquery +// request has been authenticated by the HTTP-level pre-auth middleware via +// the Authorization: NodeKey header. Downstream code uses this to skip +// redundant node-key extraction from the request body. +package osqueryauth + +import "context" + +type key int + +const ( + preAuthedKey key = iota + debugKey +) + +type preAuthedMarker struct{} + +type debugMarker struct{} + +// NewPreAuthedContext returns a ctx marked as pre-authenticated by the +// HTTP-level osquery pre-auth middleware. +func NewPreAuthedContext(ctx context.Context) context.Context { + return context.WithValue(ctx, preAuthedKey, preAuthedMarker{}) +} + +// IsPreAuthed reports whether the HTTP-level osquery pre-auth middleware +// successfully authenticated this request. +func IsPreAuthed(ctx context.Context) bool { + _, ok := ctx.Value(preAuthedKey).(preAuthedMarker) + return ok +} + +// NewDebugContext marks ctx as belonging to a host with debug logging +// enabled. The HTTP pre-auth middleware sets this when AuthenticateHost +// reports the debug flag, so the endpoint-layer authenticatedHost +// passthrough can apply the same request/response debug logging that the +// legacy body-auth path applies. +func NewDebugContext(ctx context.Context) context.Context { + return context.WithValue(ctx, debugKey, debugMarker{}) +} + +// IsDebug reports whether the request was authenticated for a host with +// debug logging enabled. +func IsDebug(ctx context.Context) bool { + _, ok := ctx.Value(debugKey).(debugMarker) + return ok +} diff --git a/server/fleet/request.go b/server/fleet/request.go index c96abdf9fc..49b89396db 100644 --- a/server/fleet/request.go +++ b/server/fleet/request.go @@ -19,6 +19,14 @@ const ( MaxMultiScriptQuerySize int64 = 5 * units.MiB MaxMicrosoftMDMSize int64 = 2 * units.MiB + // DefaultMaxOsqueryLogWriteSize is the default request body size limit + // applied to /api/osquery/log when osquery.allow_body_auth_fallback is + // true (legacy body-auth mode). Operators can override via the + // osquery.max_log_write_body_size config. In header-auth mode + // (allow_body_auth_fallback=false) this limit does not apply; the + // route inherits the global request body size limit. + DefaultMaxOsqueryLogWriteSize int64 = 10 * units.MiB + // DefaultMaxOsqueryDistributedWriteSize is the same as + // DefaultMaxOsqueryLogWriteSize but for /api/osquery/distributed/write. DefaultMaxOsqueryDistributedWriteSize int64 = 5 * units.MiB - DefaultMaxOsqueryLogWriteSize int64 = 10 * units.MiB ) diff --git a/server/platform/endpointer/endpoint_utils.go b/server/platform/endpointer/endpoint_utils.go index d97ce304ee..55a7c6921b 100644 --- a/server/platform/endpointer/endpoint_utils.go +++ b/server/platform/endpointer/endpoint_utils.go @@ -909,6 +909,10 @@ type CommonEndpointer[H any] struct { // CustomMiddlewareAfterAuth are middlewares that run after authentication. CustomMiddlewareAfterAuth []endpoint.Middleware + // HTTPPreAuthMiddleware wraps the final http.Handler, running BEFORE the + // kithttp decode-body step. + HTTPPreAuthMiddleware func(http.Handler) http.Handler + // HandlerRegistry, if set, records handlers by method+path for deprecated // path alias lookup. The pointer is shared across shallow copies (created // by builder methods like WithAltPaths) so all registrations land in the @@ -991,7 +995,13 @@ func (e *CommonEndpointer[H]) makeEndpoint(f H, v interface{}) http.Handler { // If no value is configured set default, or if the set endpoint value is less than global default use default. e.requestBodySizeLimit = platform_http.MaxRequestBodySize } - return newServer(endp, e.MakeDecoderFn(v, e.requestBodySizeLimit), e.EncodeFn, e.Opts) + h := newServer(endp, e.MakeDecoderFn(v, e.requestBodySizeLimit), e.EncodeFn, e.Opts) + // The HTTP pre-auth middleware runs outside the kithttp.Server so it can + // short-circuit requests before the decode-body step reads any bytes. + if e.HTTPPreAuthMiddleware != nil { + h = e.HTTPPreAuthMiddleware(h) + } + return h } func newServer(e endpoint.Endpoint, decodeFn kithttp.DecodeRequestFunc, encodeFn kithttp.EncodeResponseFunc, @@ -1062,6 +1072,14 @@ func (e *CommonEndpointer[H]) SkipRequestBodySizeLimit() *CommonEndpointer[H] { return &ae } +// WithHTTPPreAuth installs a raw http.Handler middleware that runs outside the +// kithttp server, before the decoder reads the body. +func (e *CommonEndpointer[H]) WithHTTPPreAuth(mw func(http.Handler) http.Handler) *CommonEndpointer[H] { + ae := *e + ae.HTTPPreAuthMiddleware = mw + return &ae +} + // PathHandler registers a handler for the verb and path. The pathHandler is // a function that receives the actual path to which it will be mounted, and // returns the actual http.Handler that will handle this endpoint. This is for diff --git a/server/platform/endpointer/endpoint_utils_test.go b/server/platform/endpointer/endpoint_utils_test.go index 05229a4347..1dd9c26c8b 100644 --- a/server/platform/endpointer/endpoint_utils_test.go +++ b/server/platform/endpointer/endpoint_utils_test.go @@ -37,7 +37,7 @@ func TestCustomMiddlewareAfterAuth(t *testing.T) { afterSecondIndex = 0 ) beforeAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { i++ beforeIndex = i return next(ctx, req) @@ -45,7 +45,7 @@ func TestCustomMiddlewareAfterAuth(t *testing.T) { } authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { i++ authIndex = i if authctx, ok := authz_ctx.FromContext(ctx); ok { @@ -56,14 +56,14 @@ func TestCustomMiddlewareAfterAuth(t *testing.T) { } afterAuthMiddlewareFirst := func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { i++ afterFirstIndex = i return next(ctx, req) } } afterAuthMiddlewareSecond := func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { i++ afterSecondIndex = i return next(ctx, req) @@ -134,6 +134,110 @@ func (n nopEP) Service() any { return nil } +// TestHTTPPreAuthMiddlewareRunsBeforeDecode asserts that HTTPPreAuthMiddleware +// short-circuits the request before the body decoder is invoked. +func TestHTTPPreAuthMiddlewareRunsBeforeDecode(t *testing.T) { + var decodeCalled bool + var authCalled bool + + authMw := func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req any) (any, error) { + authCalled = true + return next(ctx, req) + } + } + + r := mux.NewRouter() + ce := (&CommonEndpointer[testHandlerFunc]{ + EP: nopEP{}, + MakeDecoderFn: func(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc { + return func(ctx context.Context, r *http.Request) (any, error) { + decodeCalled = true + return nopRequest{}, nil + } + }, + EncodeFn: func(ctx context.Context, w http.ResponseWriter, i any) error { + w.WriteHeader(http.StatusOK) + return nil + }, + AuthMiddleware: authMw, + Router: r, + }).WithHTTPPreAuth(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Reject without calling next — decoder and auth must not run. + w.WriteHeader(http.StatusUnauthorized) + }) + }) + + ce.handleEndpoint("/", func(ctx context.Context, request any) (platform_http.Errorer, error) { + return nopResponse{}, nil + }, nil, "POST") + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + resp, err := http.Post(srv.URL+"/", "application/json", strings.NewReader(`{"x":1}`)) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.False(t, decodeCalled, "decoder must not run when pre-auth rejects") + assert.False(t, authCalled, "auth middleware must not run when pre-auth rejects") +} + +// TestHTTPPreAuthMiddlewarePassThrough asserts that when the pre-auth +// middleware calls next, the decoder and auth chain run as normal. +func TestHTTPPreAuthMiddlewarePassThrough(t *testing.T) { + var decodeCalled bool + var authCalled bool + + authMw := func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req any) (any, error) { + authCalled = true + if authctx, ok := authz_ctx.FromContext(ctx); ok { + authctx.SetChecked() + } + return next(ctx, req) + } + } + + r := mux.NewRouter() + ce := (&CommonEndpointer[testHandlerFunc]{ + EP: nopEP{}, + MakeDecoderFn: func(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc { + return func(ctx context.Context, r *http.Request) (any, error) { + decodeCalled = true + return nopRequest{}, nil + } + }, + EncodeFn: func(ctx context.Context, w http.ResponseWriter, i any) error { + w.WriteHeader(http.StatusOK) + return nil + }, + AuthMiddleware: authMw, + Router: r, + }).WithHTTPPreAuth(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + }) + + ce.handleEndpoint("/", func(ctx context.Context, request any) (platform_http.Errorer, error) { + return nopResponse{}, nil + }, nil, "POST") + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + resp, err := http.Post(srv.URL+"/", "application/json", strings.NewReader(`{"x":1}`)) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, decodeCalled, "decoder must run when pre-auth passes through") + assert.True(t, authCalled, "auth middleware must run when pre-auth passes through") +} + func TestRegisterDeprecatedPathAliases(t *testing.T) { // Set up a router and register a primary endpoint via CommonEndpointer. r := mux.NewRouter() diff --git a/server/service/carves.go b/server/service/carves.go index c204e0d738..8ca97b8894 100644 --- a/server/service/carves.go +++ b/server/service/carves.go @@ -273,6 +273,14 @@ func (r carveBlockResponse) Error() error { return r.Err } // stable for many years) and parse the body field by field. The "session_id" and "request_id" always // come before the "data" field; thus Fleet will extract "session_id" and "request_id", perform authentication // and if the credentials are valid parse and decode the "data" field. +// +// The Authorization: NodeKey header (validated by the HTTP pre-auth +// middleware) is treated as an additional short-circuit gate: a present-but- +// invalid header rejects the request before this parser ever runs. When the +// header is absent or valid, this parser still runs — the session/request_id +// check remains the primary authentication mechanism. When the header is +// valid, CarveBlock additionally verifies that the carve's host_id matches +// the authenticated host. func (r carveBlockRequest) DecodeRequest(ctx context.Context, req *http.Request) (any, error) { carveStore := carvestorectx.FromContext(ctx) if carveStore == nil { @@ -441,8 +449,18 @@ func (svc *Service) CarveBlock(ctx context.Context, payload fleet.CarveBlockPayl // skipauth: Authorization is currently for user endpoints only. svc.authz.SkipAuthorization(ctx) - // Note host did not authenticate via node key. We need to authenticate them - // by the session ID and request ID + // Authentication on this endpoint is layered: + // 1. The streaming body parser (carveBlockRequest.DecodeRequest) + // verifies session_id+request_id against the carve store. + // 2. When osquery.allow_body_auth_fallback=false, the HTTP pre-auth + // middleware (osqueryCarveBlockHeaderPreAuth) is installed and + // additionally validates a NodeKey header before the body is read, + // stashing the authenticated host in ctx. The check below uses + // that host to verify carve ownership — ensuring one host cannot + // post blocks into another host's carve session. + // 3. With osquery.allow_body_auth_fallback=true (default), the + // pre-auth middleware is not installed; no host ends up in ctx, + // and the ownership check is skipped. carve, err := svc.carveStore.CarveBySessionId(ctx, payload.SessionId) if err != nil { return ctxerr.Wrap(ctx, err, "find carve by session_id") @@ -452,6 +470,14 @@ func (svc *Service) CarveBlock(ctx context.Context, payload fleet.CarveBlockPayl return errors.New("request_id does not match") } + if host, ok := hostctx.FromContext(ctx); ok && host.ID != carve.HostId { + logging.WithExtras(ctx, "carve_host_id", carve.HostId, "authed_host_id", host.ID, + "reason", "carve host ownership mismatch") + ose := newOsqueryError("authentication error") + ose.StatusCode = http.StatusUnauthorized + return ose + } + // Request is now authenticated if err := svc.validateCarveBlock(payload, carve); err != nil { diff --git a/server/service/carves_test.go b/server/service/carves_test.go index 6c3c0a02bd..367cf387ac 100644 --- a/server/service/carves_test.go +++ b/server/service/carves_test.go @@ -420,6 +420,138 @@ func TestCarveCarveBlockGetCarveError(t *testing.T) { assert.Contains(t, err.Error(), "ouch!") } +// TestCarveBlockHostOwnershipMismatch verifies that when the HTTP pre-auth +// has stashed an authenticated host in ctx, CarveBlock rejects the request +// if the carve's HostId doesn't match. +func TestCarveBlockHostOwnershipMismatch(t *testing.T) { + sessionId := "sess" + metadata := &fleet.CarveMetadata{ + ID: 2, + HostId: 3, + BlockCount: 23, + BlockSize: 64, + CarveSize: 23 * 64, + RequestId: "req", + SessionId: sessionId, + MaxBlock: 3, + } + ms := new(mock.Store) + ms.CarveBySessionIdFunc = func(ctx context.Context, sessionId string) (*fleet.CarveMetadata, error) { + return metadata, nil + } + + svcAuthz, err := authz.NewAuthorizer() + require.NoError(t, err) + svc := &Service{carveStore: ms, authz: svcAuthz} + + payload := fleet.CarveBlockPayload{ + Data: []byte("data"), + RequestId: "req", + SessionId: sessionId, + BlockId: 4, + } + + // Host 999 is NOT the carve owner (carve.HostId == 3). + attackerHost := &fleet.Host{ID: 999} + ctx := hostctx.NewContext(context.Background(), attackerHost) + + err = svc.CarveBlock(ctx, payload) + require.Error(t, err) + // Ownership failure must surface as a top-level *OsqueryError with + // 401 status — top-level (no ctxerr.Wrap) is required because + // FleetErrorEncoder uses a type switch on err that does not unwrap. + // Wrapping would cause the encoder to fall through to the generic + // JSON error shape instead of the osquery-style response. + ose, ok := err.(*OsqueryError) + require.True(t, ok, "ownership-failure must be returned as *OsqueryError directly, not wrapped via ctxerr.Wrap (else FleetErrorEncoder type switch can't see it)") + assert.Equal(t, http.StatusUnauthorized, ose.Status()) + assert.False(t, ose.NodeInvalid(), "node_invalid must be false on ownership failure — the node_key is valid") + // The response body uses a generic message to avoid disclosing carve + // existence/ownership to callers; the specific reason is recorded in + // the server log via logging.WithExtras. + assert.Equal(t, "authentication error", ose.Error()) + // NewBlock must NOT be called when ownership fails. + assert.False(t, ms.NewBlockFuncInvoked) +} + +// TestCarveBlockHostOwnershipMatch verifies the happy path where the +// pre-authed host in ctx matches the carve's HostId. +func TestCarveBlockHostOwnershipMatch(t *testing.T) { + sessionId := "sess" + metadata := &fleet.CarveMetadata{ + ID: 2, + HostId: 7, + BlockCount: 10, + BlockSize: 64, + CarveSize: 10 * 64, + RequestId: "req", + SessionId: sessionId, + MaxBlock: 3, + } + ms := new(mock.Store) + ms.CarveBySessionIdFunc = func(ctx context.Context, sessionId string) (*fleet.CarveMetadata, error) { + return metadata, nil + } + ms.NewBlockFunc = func(ctx context.Context, c *fleet.CarveMetadata, blockId int64, data []byte) error { + return nil + } + + svcAuthz, err := authz.NewAuthorizer() + require.NoError(t, err) + svc := &Service{carveStore: ms, authz: svcAuthz} + + payload := fleet.CarveBlockPayload{ + Data: []byte("data"), + RequestId: "req", + SessionId: sessionId, + BlockId: 4, + } + + ownerHost := &fleet.Host{ID: 7} + ctx := hostctx.NewContext(context.Background(), ownerHost) + + err = svc.CarveBlock(ctx, payload) + require.NoError(t, err) + assert.True(t, ms.NewBlockFuncInvoked) +} + +// TestCarveBlockNoHostInCtxSkipsOwnershipCheck verifies that when no host is +// in ctx (header-absent path), CarveBlock does NOT perform the ownership +// check — session_id + request_id alone is the auth. +func TestCarveBlockNoHostInCtxSkipsOwnershipCheck(t *testing.T) { + sessionId := "sess" + metadata := &fleet.CarveMetadata{ + ID: 2, + HostId: 7, + BlockCount: 10, + BlockSize: 64, + CarveSize: 10 * 64, + RequestId: "req", + SessionId: sessionId, + MaxBlock: 3, + } + ms := new(mock.Store) + ms.CarveBySessionIdFunc = func(ctx context.Context, sessionId string) (*fleet.CarveMetadata, error) { + return metadata, nil + } + ms.NewBlockFunc = func(ctx context.Context, c *fleet.CarveMetadata, blockId int64, data []byte) error { + return nil + } + + svcAuthz, err := authz.NewAuthorizer() + require.NoError(t, err) + svc := &Service{carveStore: ms, authz: svcAuthz} + + err = svc.CarveBlock(context.Background(), fleet.CarveBlockPayload{ + Data: []byte("data"), + RequestId: "req", + SessionId: sessionId, + BlockId: 4, + }) + require.NoError(t, err) + assert.True(t, ms.NewBlockFuncInvoked) +} + func TestCarveCarveBlockRequestIdError(t *testing.T) { sessionId := "foobar" metadata := &fleet.CarveMetadata{ diff --git a/server/service/endpoint_middleware.go b/server/service/endpoint_middleware.go index 228b38866e..05a234b5d6 100644 --- a/server/service/endpoint_middleware.go +++ b/server/service/endpoint_middleware.go @@ -12,6 +12,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/certserial" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" + "github.com/fleetdm/fleet/v4/server/contexts/osqueryauth" "github.com/fleetdm/fleet/v4/server/fleet" middleware_log "github.com/fleetdm/fleet/v4/server/service/middleware/log" kithttp "github.com/go-kit/kit/transport/http" @@ -134,9 +135,41 @@ func getDeviceAuthToken(r interface{}) (string, error) { // authenticatedHost wraps an endpoint, checks the validity of the node_key // provided in the request, and attaches the corresponding osquery host to the -// context for the request +// context for the request. +// +// If the HTTP pre-auth middleware (osqueryHeaderPreAuth) has already +// authenticated the request via the Authorization: NodeKey header, +// the hostctx and related ctx setup is already in place and this middleware +// becomes a passthrough. func authenticatedHost(svc fleet.Service, logger *slog.Logger, next endpoint.Endpoint) endpoint.Endpoint { authHostFunc := func(ctx context.Context, request interface{}) (interface{}, error) { + // HTTP pre-auth already authenticated the request and populated + // hostctx. + if osqueryauth.IsPreAuthed(ctx) { + host, ok := hostctx.FromContext(ctx) + if !ok { + return nil, ctxerr.New(ctx, "osquery pre-auth marker set without host in ctx") + } + instrumentHostLogger(ctx, host.ID) + if ac, ok := authz_ctx.FromContext(ctx); ok { + ac.SetAuthnMethod(authz_ctx.AuthnHostToken) + } + debug := osqueryauth.IsDebug(ctx) + var hlogger *slog.Logger + if debug { + hlogger = logger.With("host_id", host.ID) + logJSON(ctx, hlogger, request, "request") + } + resp, err := next(ctx, request) + if err != nil { + return nil, err + } + if debug { + logJSON(ctx, hlogger, resp, "response") + } + return resp, nil + } + nodeKey, err := getNodeKey(request) if err != nil { return nil, err diff --git a/server/service/handler.go b/server/service/handler.go index ecda64f145..75fa42bc1c 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -927,7 +927,26 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC demdm.AppendCustomMiddleware(errorLimiter).POST("/api/_version_/fleet/device/{token}/migrate_mdm", migrateMDMDeviceEndpoint, deviceMigrateMDMRequest{}) // host-authenticated endpoints + // + // The HTTP-level pre-auth middleware authenticates osquery requests via the + // Authorization: NodeKey header BEFORE the request body is read. + // + // osquery.allow_body_auth_fallback selects which auth scheme is in + // effect: + // - true (default): header is ignored entirely; body-based + // auth is the sole authenticator. + // - false: header is required; body-based auth is not + // consulted. Pre-auth rejects on absent/invalid headers before the + // body is read. + // + // `he` is the base host-authenticated endpointer with no pre-auth wrap. + // `heHeader` is the same endpointer optionally wrapped with the + // header pre-auth in strict mode. he := newHostAuthenticatedEndpointer(svc, logger, opts, r, apiVersions...) + heHeader := he + if !config.Osquery.AllowBodyAuthFallback { + heHeader = he.WithHTTPPreAuth(osqueryHeaderPreAuth(svc, logger)) + } // Note that the /osquery/ endpoints are *not* versioned, i.e. there is no // `_version_` placeholder in the path. This is deliberate, see @@ -936,23 +955,40 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // but even that `v1` is *not* part of the standard versioning, it will still // work even after we remove support for the `v1` version for the rest of the // API. This allows us to deprecate osquery endpoints separately. - he.WithAltPaths("/api/v1/osquery/config"). + heHeader.WithAltPaths("/api/v1/osquery/config"). POST("/api/osquery/config", getClientConfigEndpoint, getClientConfigRequest{}) - he.WithAltPaths("/api/v1/osquery/distributed/read"). + heHeader.WithAltPaths("/api/v1/osquery/distributed/read"). POST("/api/osquery/distributed/read", getDistributedQueriesEndpoint, getDistributedQueriesRequest{}) - distWriteLimit := config.Osquery.MaxDistributedWriteBodySize - if distWriteLimit == 0 { - distWriteLimit = fleet.DefaultMaxOsqueryDistributedWriteSize + // /distributed/write and /log accept large payloads. The body-size + // policy depends on which auth scheme is in effect: + // - body-auth mode: per-route limit (operator-tunable via + // MaxLogWriteBodySize / MaxDistributedWriteBodySize, defaulting to + // the historical DefaultMaxOsquery* constants). + // - header-auth mode: no per-route limit. Once pre-auth accepts the + // request via Authorization: NodeKey, the agent is authenticated + // and the body can be any size. + distWriteReg, logWriteReg := heHeader, heHeader + if config.Osquery.AllowBodyAuthFallback { + distLimit := config.Osquery.MaxDistributedWriteBodySize + if distLimit == 0 { + distLimit = fleet.DefaultMaxOsqueryDistributedWriteSize + } + distWriteReg = heHeader.WithRequestBodySizeLimit(distLimit) + + logLimit := config.Osquery.MaxLogWriteBodySize + if logLimit == 0 { + logLimit = fleet.DefaultMaxOsqueryLogWriteSize + } + logWriteReg = heHeader.WithRequestBodySizeLimit(logLimit) + } else { + distWriteReg = heHeader.SkipRequestBodySizeLimit() + logWriteReg = heHeader.SkipRequestBodySizeLimit() } - he.WithRequestBodySizeLimit(distWriteLimit).WithAltPaths("/api/v1/osquery/distributed/write"). + distWriteReg.WithAltPaths("/api/v1/osquery/distributed/write"). POST("/api/osquery/distributed/write", submitDistributedQueryResultsEndpoint, submitDistributedQueryResultsRequestShim{}) - he.WithAltPaths("/api/v1/osquery/carve/begin"). + heHeader.WithAltPaths("/api/v1/osquery/carve/begin"). POST("/api/osquery/carve/begin", carveBeginEndpoint, carveBeginRequest{}) - logWriteLimit := config.Osquery.MaxLogWriteBodySize - if logWriteLimit == 0 { - logWriteLimit = fleet.DefaultMaxOsqueryLogWriteSize - } - he.WithRequestBodySizeLimit(logWriteLimit).WithAltPaths("/api/v1/osquery/log"). + logWriteReg.WithAltPaths("/api/v1/osquery/log"). POST("/api/osquery/log", submitLogsEndpoint, submitLogsRequest{}) he.WithAltPaths("/api/v1/osquery/yara/{name}"). POST("/api/osquery/yara/{name}", getYaraEndpoint, getYaraRequest{}) @@ -1068,7 +1104,20 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // For some reason osquery does not provide a node key with the block data. // Instead the carve session ID should be verified in the service method. // Since []byte slices is encoded as base64 in JSON, increase the limit to 1.5x - ne.SkipRequestBodySizeLimit().WithAltPaths("/api/v1/osquery/carve/block"). + // + // When osquery.allow_body_auth_fallback is false the + // osqueryCarveBlockHeaderPreAuth wrapper is installed: it requires a + // valid Authorization: NodeKey header (rejecting absent or invalid + // headers before the body is read) and stashes the authenticated host + // in ctx so CarveBlock can enforce the carve-ownership check. When + // the flag is true the wrapper is + // not installed and /carve/block falls back to its existing + // streaming-parse auth (session_id + request_id only). + carveBlockReg := ne.SkipRequestBodySizeLimit() + if !config.Osquery.AllowBodyAuthFallback { + carveBlockReg = carveBlockReg.WithHTTPPreAuth(osqueryCarveBlockHeaderPreAuth(svc, logger)) + } + carveBlockReg.WithAltPaths("/api/v1/osquery/carve/block"). POST("/api/osquery/carve/block", carveBlockEndpoint, carveBlockRequest{}) ne.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/package/token/{token}", downloadSoftwareInstallerEndpoint, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6a5071ea4c..95fd34b883 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -16336,26 +16336,21 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplateSpec() { } } -// TestOsqueryBodySizeLimit verifies the body size limits on the -// /api/osquery/log and /api/osquery/distributed/write endpoints: -// - Bodies exceeding the default limit are rejected with HTTP 413. -// - Bodies within the limit are accepted. -// - A malformed (truncated) body within the limit is NOT reported as HTTP 413 -// (guards against the false-positive PayloadTooLargeError fix). -// - Setting Osquery.MaxLogWriteBodySize / MaxDistributedWriteBodySize in the -// server config overrides the built-in defaults. func (s *integrationTestSuite) TestOsqueryBodySizeLimit() { t := s.T() host := createOrbitEnrolledHost(t, "linux", "body-limit", s.ds) - // Body over DefaultMaxOsqueryLogWriteSize must be rejected with 413. The padding + logLimit := int(fleet.DefaultMaxOsqueryLogWriteSize) + distLimit := int(fleet.DefaultMaxOsqueryDistributedWriteSize) + + // Body over the per-route default must be rejected with 413. The padding // is inside a JSON string value so the body is syntactically valid up to // the point where the reader is cut off. logPrefix := fmt.Sprintf(`{"node_key":%q,"log_type":"status","data":["`, *host.NodeKey) logSuffix := `"]}` - logPadSize := int(fleet.DefaultMaxOsqueryLogWriteSize) + 1 - len(logPrefix) - len(logSuffix) - require.Positive(t, logPadSize, "padding must be positive; DefaultMaxOsqueryLogWriteSize may be too small") + logPadSize := logLimit + 1 - len(logPrefix) - len(logSuffix) + require.Positive(t, logPadSize, "padding must be positive") overLimitLog := []byte(logPrefix + strings.Repeat("x", logPadSize) + logSuffix) s.DoRawNoAuth("POST", "/api/osquery/log", overLimitLog, http.StatusRequestEntityTooLarge) @@ -16375,11 +16370,11 @@ func (s *integrationTestSuite) TestOsqueryBodySizeLimit() { truncatedLog := fmt.Appendf(nil, `{"node_key":%q,"log_type":"status","data":[`, *host.NodeKey) // missing closing ]} s.DoRawNoAuth("POST", "/api/osquery/log", truncatedLog, http.StatusBadRequest) - // Body over DefaultMaxOsqueryDistributedWriteSize must be rejected with 413. + // Body over the per-route default must be rejected with 413. distPrefix := fmt.Sprintf(`{"node_key":%q,"queries":{"q1":[{"data":"`, *host.NodeKey) distSuffix := `"}]},"statuses":{"q1":0},"messages":{},"stats":{}}` - distPadSize := int(fleet.DefaultMaxOsqueryDistributedWriteSize) + 1 - len(distPrefix) - len(distSuffix) - require.Positive(t, distPadSize, "padding must be positive; DefaultMaxOsqueryDistributedWriteSize may be too small") + distPadSize := distLimit + 1 - len(distPrefix) - len(distSuffix) + require.Positive(t, distPadSize, "padding must be positive") overLimitDist := []byte(distPrefix + strings.Repeat("x", distPadSize) + distSuffix) s.DoRawNoAuth("POST", "/api/osquery/distributed/write", overLimitDist, http.StatusRequestEntityTooLarge) @@ -16399,9 +16394,10 @@ func (s *integrationTestSuite) TestOsqueryBodySizeLimit() { truncatedDist := fmt.Appendf(nil, `{"node_key":%q,"queries":{"q1":[`, *host.NodeKey) // missing closing s.DoRawNoAuth("POST", "/api/osquery/distributed/write", truncatedDist, http.StatusBadRequest) - // Verify that Osquery.MaxLogWriteBodySize and MaxDistributedWriteBodySize - // in the server config override the built-in defaults. - s.Run("config override", func() { + s.Run("config overrides take effect in body-auth mode", func() { + // Spin up a second server with custom per-route limits and + // confirm bodies above the override are rejected while bodies + // below are accepted. const customLimit = 2 * units.MiB cfg := config.TestConfig() @@ -16416,22 +16412,52 @@ func (s *integrationTestSuite) TestOsqueryBodySizeLimit() { ts := withServer{server: customServer} ts.s = &s.Suite - // body over the custom limit must return 413. logPad := customLimit + 1 - len(logPrefix) - len(logSuffix) - require.Positive(s.T(), logPad, "padding must be positive; customLimit may be too small") - ts.DoRawNoAuth("POST", "/api/osquery/log", []byte(logPrefix+strings.Repeat("x", logPad)+logSuffix), http.StatusRequestEntityTooLarge) - - // body within the custom limit must succeed. + s.Require().Positive(logPad) + ts.DoRawNoAuth("POST", "/api/osquery/log", + []byte(logPrefix+strings.Repeat("x", logPad)+logSuffix), + http.StatusRequestEntityTooLarge) ts.DoRawNoAuth("POST", "/api/osquery/log", withinLimitLog, http.StatusOK) - // body over the custom limit must return 413. distPad := customLimit + 1 - len(distPrefix) - len(distSuffix) - require.Positive(s.T(), distPad, "padding must be positive; customLimit may be too small") - ts.DoRawNoAuth("POST", "/api/osquery/distributed/write", []byte(distPrefix+strings.Repeat("x", distPad)+distSuffix), http.StatusRequestEntityTooLarge) - - // body within the custom limit must succeed. + s.Require().Positive(distPad) + ts.DoRawNoAuth("POST", "/api/osquery/distributed/write", + []byte(distPrefix+strings.Repeat("x", distPad)+distSuffix), + http.StatusRequestEntityTooLarge) ts.DoRawNoAuth("POST", "/api/osquery/distributed/write", withinLimitDist, http.StatusOK) }) + + s.Run("header-auth mode imposes no body size limit", func() { + // In header-auth mode the per-route configs are intentionally + // ignored AND no body size limit applies. A body well above the + // global default (and well above any per-route default) must + // succeed when authenticated via header. + cfg := config.TestConfig() + cfg.Osquery.AllowBodyAuthFallback = false + cfg.Osquery.MaxLogWriteBodySize = 1 * units.MiB // ignored + cfg.Osquery.MaxDistributedWriteBodySize = 1 * units.MiB // ignored + + _, customServer := RunServerForTestsWithDS(s.T(), s.ds, &TestServerOpts{ + FleetConfig: &cfg, + SkipCreateTestUsers: true, + }) + s.T().Cleanup(customServer.Close) + ts := withServer{server: customServer} + ts.s = &s.Suite + + // 12 MiB body — over both the global limit (1 MiB) and any + // per-route default. Must succeed because header-auth mode + // applies no body size constraint. + oversizedLog, err := json.Marshal(submitLogsRequest{ + NodeKey: *host.NodeKey, + LogType: "status", + Data: []json.RawMessage{json.RawMessage(`"` + strings.Repeat("x", 12*1024*1024) + `"`)}, + }) + s.Require().NoError(err) + ts.DoRawWithHeaders("POST", "/api/osquery/log", oversizedLog, + http.StatusOK, + map[string]string{"Authorization": "NodeKey " + *host.NodeKey}) + }) } func (s *integrationTestSuite) TestListHostReports() { diff --git a/server/service/integration_osquery_headerauth_test.go b/server/service/integration_osquery_headerauth_test.go new file mode 100644 index 0000000000..962918b90f --- /dev/null +++ b/server/service/integration_osquery_headerauth_test.go @@ -0,0 +1,244 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + platform_http "github.com/fleetdm/fleet/v4/server/platform/http" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegrationsOsqueryHeaderAuth covers the end-to-end behavior of +// osquery host authentication through the real HTTP handler stack +// (kithttp.ServerBefore hooks, decoders, authzcheck, error encoding) under +// both osquery.allow_body_auth_fallback modes: +// +// - Default mode (flag=true): the Authorization: NodeKey header is ignored +// entirely. Legacy body-based auth is the sole authenticator. The +// pre-auth middleware is NOT installed in this mode. +// - Strict mode (flag=false): the Authorization: NodeKey header is +// required. Body-based auth is not consulted. Pre-auth rejects +// absent/invalid headers before the body is read. +// +// See unit tests in osquery_header_auth_test.go for isolated middleware +// coverage. +func (s *integrationTestSuite) TestIntegrationsOsqueryHeaderAuth() { + t := s.T() + ctx := context.Background() + + // Create a host with a known node_key. + nodeKey := t.Name() + "-nodekey" + host, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(nodeKey), + UUID: uuid.New().String(), + Hostname: t.Name() + ".local", + Platform: "linux", + }) + require.NoError(t, err) + require.NotNil(t, host) + + // Helper: build a minimal valid submit-logs body with the given node_key. + makeLogBody := func(bodyNodeKey string) []byte { + body, err := json.Marshal(submitLogsRequest{ + NodeKey: bodyNodeKey, + LogType: "status", + Data: []json.RawMessage{json.RawMessage(`{}`)}, + }) + require.NoError(t, err) + return body + } + + // Helper: assert the 401 response body contains node_invalid:true. + assertNodeInvalid := func(resp *http.Response) { + t.Helper() + defer resp.Body.Close() + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, true, body["node_invalid"], "response should have node_invalid:true; got: %v", body) + } + + // -------------------------------------------------------------------- + // Default mode: allow_body_auth_fallback=true (the suite's default). + // The pre-auth middleware is NOT installed. The Authorization header + // is ignored entirely; body-based auth is the sole authenticator. + // -------------------------------------------------------------------- + + t.Run("default: body node_key alone authenticates", func(t *testing.T) { + resp := s.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(nodeKey), + http.StatusOK, map[string]string{}) + defer resp.Body.Close() + }) + + t.Run("default: header is ignored — invalid header + valid body still 200", func(t *testing.T) { + resp := s.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(nodeKey), + http.StatusOK, map[string]string{"Authorization": "NodeKey bogus-token"}) + defer resp.Body.Close() + }) + + t.Run("default: header is ignored — valid header + invalid body still 401", func(t *testing.T) { + resp := s.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody("bogus-body-token"), + http.StatusUnauthorized, map[string]string{"Authorization": "NodeKey " + nodeKey}) + assertNodeInvalid(resp) + }) + + t.Run("default: invalid body node_key rejects via legacy auth", func(t *testing.T) { + resp := s.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody("bogus-body-token"), + http.StatusUnauthorized, map[string]string{}) + assertNodeInvalid(resp) + }) + + t.Run("default: enroll endpoint unaffected", func(t *testing.T) { + // /api/osquery/enroll uses enroll_secret, not node_key. The header + // pre-auth wiring never touches this route under any flag value. + body := fmt.Sprintf(`{"enroll_secret":"nosuchsecret","host_identifier":"%s"}`, t.Name()) + resp := s.DoRawWithHeaders("POST", "/api/osquery/enroll", []byte(body), + http.StatusUnauthorized, map[string]string{"Authorization": "NodeKey bogus"}) + defer resp.Body.Close() + }) + + t.Run("default: yara endpoint uses body auth", func(t *testing.T) { + body := fmt.Appendf(nil, `{"node_key":%q}`, nodeKey) + // 404 = body auth succeeded, rule not found in datastore. + resp := s.DoRawWithHeaders("POST", "/api/osquery/yara/no-such-rule", body, + http.StatusNotFound, map[string]string{}) + resp.Body.Close() + + resp2 := s.DoRawWithHeaders("POST", "/api/osquery/yara/no-such-rule", + []byte(`{"node_key":"bogus"}`), + http.StatusUnauthorized, map[string]string{}) + assertNodeInvalid(resp2) + }) + + // -------------------------------------------------------------------- + // Strict mode: allow_body_auth_fallback=false. Spin up a second server + // on the same DB so we exercise the pre-auth wiring through the real + // kithttp stack. + // -------------------------------------------------------------------- + + t.Run("strict mode (allow_body_auth_fallback=false)", func(t *testing.T) { + cfg := config.TestConfig() + cfg.Osquery.AllowBodyAuthFallback = false + + _, customServer := RunServerForTestsWithDS(t, s.ds, &TestServerOpts{ + FleetConfig: &cfg, + SkipCreateTestUsers: true, + }) + t.Cleanup(customServer.Close) + ts := withServer{server: customServer} + ts.s = &s.Suite + + t.Run("valid NodeKey header → 200", func(t *testing.T) { + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(""), + http.StatusOK, map[string]string{"Authorization": "NodeKey " + nodeKey}) + defer resp.Body.Close() + }) + + t.Run("case-insensitive scheme accepted", func(t *testing.T) { + for _, scheme := range []string{"nodekey", "NODEKEY", "NoDeKeY"} { + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(""), + http.StatusOK, map[string]string{"Authorization": scheme + " " + nodeKey}) + resp.Body.Close() + } + }) + + t.Run("invalid NodeKey header → 401", func(t *testing.T) { + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(""), + http.StatusUnauthorized, map[string]string{"Authorization": "NodeKey bogus-token"}) + assertNodeInvalid(resp) + }) + + t.Run("absent header → 401 (no body fallback)", func(t *testing.T) { + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(nodeKey), + http.StatusUnauthorized, map[string]string{}) + assertNodeInvalid(resp) + }) + + t.Run("wrong scheme → 401", func(t *testing.T) { + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", makeLogBody(nodeKey), + http.StatusUnauthorized, map[string]string{"Authorization": "Bearer " + nodeKey}) + assertNodeInvalid(resp) + }) + + t.Run("invalid header rejects before body is read", func(t *testing.T) { + // Send a body well above the global request size limit. If pre-auth + // rejects before reading the body, we get a clean 401. If the body + // were read, we'd get a 413 PayloadTooLarge. + padSize := int(platform_http.MaxRequestBodySize) * 2 + var buf bytes.Buffer + buf.WriteByte('{') + buf.WriteString(`"node_key":"`) + buf.WriteString(nodeKey) + buf.WriteString(`","log_type":"status","data":["`) + buf.WriteString(strings.Repeat("A", padSize)) + buf.WriteString(`"]}`) + + resp := ts.DoRawWithHeaders("POST", "/api/osquery/log", buf.Bytes(), + http.StatusUnauthorized, map[string]string{"Authorization": "NodeKey bogus"}) + assertNodeInvalid(resp) + }) + + t.Run("/distributed/write also strict-gated", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "node_key": "", + "queries": map[string]any{}, + "statuses": map[string]any{}, + }) + require.NoError(t, err) + + resp := ts.DoRawWithHeaders("POST", "/api/osquery/distributed/write", body, + http.StatusOK, map[string]string{"Authorization": "NodeKey " + nodeKey}) + resp.Body.Close() + + resp2 := ts.DoRawWithHeaders("POST", "/api/osquery/distributed/write", body, + http.StatusUnauthorized, map[string]string{"Authorization": "NodeKey bogus"}) + assertNodeInvalid(resp2) + }) + + t.Run("/carve/block strict-gated", func(t *testing.T) { + // In strict mode the pre-auth wrapper on /carve/block requires + // a valid header before the streaming parser runs. Absent + // header → 401 even though session_id+request_id auth is the + // streaming parser's mechanism. + body := `{"block_id":0,"session_id":"does-not-exist","request_id":"x","data":"aGk="}` + resp := ts.DoRawWithHeaders("POST", "/api/osquery/carve/block", []byte(body), + http.StatusUnauthorized, map[string]string{}) + assertNodeInvalid(resp) + }) + + t.Run("/yara/{name} exempt from strict mode (uses body auth)", func(t *testing.T) { + // 404 means body auth succeeded and the request reached the + // service layer, where the rule lookup fails. If pre-auth had + // applied to /yara, an absent header would have produced 401 + // with "missing or malformed Authorization header". + body := fmt.Appendf(nil, `{"node_key":%q}`, nodeKey) + resp := ts.DoRawWithHeaders("POST", "/api/osquery/yara/no-such-rule", body, + http.StatusNotFound, map[string]string{}) + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + assert.NotContains(t, string(respBody), "missing or malformed Authorization header") + + resp2 := ts.DoRawWithHeaders("POST", "/api/osquery/yara/no-such-rule", + []byte(`{"node_key":"bogus"}`), + http.StatusUnauthorized, map[string]string{}) + assertNodeInvalid(resp2) + }) + }) +} diff --git a/server/service/osquery_header_auth.go b/server/service/osquery_header_auth.go new file mode 100644 index 0000000000..0c0ea0604f --- /dev/null +++ b/server/service/osquery_header_auth.go @@ -0,0 +1,173 @@ +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 +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 " 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)) + }) + } +} diff --git a/server/service/osquery_header_auth_test.go b/server/service/osquery_header_auth_test.go new file mode 100644 index 0000000000..e86ec26f28 --- /dev/null +++ b/server/service/osquery_header_auth_test.go @@ -0,0 +1,370 @@ +package service + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" + "github.com/fleetdm/fleet/v4/server/contexts/osqueryauth" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bodyTracker is a ReadCloser that records whether Read was ever called, +// so tests can assert that the request body is never consumed when the +// header pre-auth short-circuits. +type bodyTracker struct { + io.Reader + read int32 +} + +func (b *bodyTracker) Read(p []byte) (int, error) { + atomic.StoreInt32(&b.read, 1) + return b.Reader.Read(p) +} + +func (b *bodyTracker) Close() error { return nil } + +func (b *bodyTracker) wasRead() bool { + return atomic.LoadInt32(&b.read) == 1 +} + +func TestExtractNodeKeyFromHeader(t *testing.T) { + tests := []struct { + authHeader string + want string + }{ + {"", ""}, + {"NodeKey abc123", "abc123"}, + {"NodeKey abc123 ", "abc123"}, + {"NodeKey ", ""}, + {"NodeKey", ""}, + {"Bearer abc123", ""}, + {"Node key abc123", ""}, // Orbit's scheme, must not match + {"nodekey abc123", "abc123"}, // case-insensitive scheme per RFC 7235 + {"NODEKEY abc123", "abc123"}, + {"NoDeKeY abc123", "abc123"}, + {"NodeKeyabc123", ""}, // missing space + {"NodeKey\tabc123", ""}, // tab is not a space + {"NodeKey abc def", ""}, // embedded space rejected (defense against auth-params) + {"NodeKey abc\tdef", ""}, // embedded tab rejected + {"NodeKey " + strings.Repeat("A", 4096), strings.Repeat("A", 4096)}, // long token + } + for _, tt := range tests { + t.Run(tt.authHeader, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/osquery/log", strings.NewReader("")) + if tt.authHeader != "" { + r.Header.Set("Authorization", tt.authHeader) + } + got := extractNodeKeyFromHeader(r) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOsqueryHeaderPreAuth(t *testing.T) { + const goodNodeKey = "valid-node-key" + host := &fleet.Host{ID: 42, Hostname: "test-host", HasHostIdentityCert: new(false)} + + newSvc := func(t *testing.T) (fleet.Service, *mock.Store) { + ds := new(mock.Store) + svc, _ := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.LoadHostByNodeKeyFunc = func(ctx context.Context, nodeKey string) (*fleet.Host, error) { + if nodeKey == goodNodeKey { + return host, nil + } + return nil, newNotFoundError() + } + return svc, ds + } + + type testCase struct { + name string + authHeader string + wantNextCalled bool + wantPreAuthedInCtx bool + wantStatus int + wantBodyRead bool + } + + // The pre-auth middleware is only installed when allow_body_auth_fallback + // is false (handler.go gates the .WithHTTPPreAuth(...) call). In that + // strict-mode all non-valid headers reject; valid ones populate ctx. + cases := []testCase{ + { + name: "valid header", + authHeader: "NodeKey " + goodNodeKey, + wantNextCalled: true, + wantPreAuthedInCtx: true, + wantStatus: http.StatusOK, + }, + { + name: "invalid header", + authHeader: "NodeKey bogus", + wantNextCalled: false, + wantStatus: http.StatusUnauthorized, + }, + { + name: "absent header", + authHeader: "", + wantNextCalled: false, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong scheme", + authHeader: "Bearer " + goodNodeKey, + wantNextCalled: false, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, _ := newSvc(t) + + var nextCalled bool + var ctxFromNext context.Context + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + ctxFromNext = r.Context() + w.WriteHeader(http.StatusOK) + }) + + mw := osqueryHeaderPreAuth(svc, slog.New(slog.DiscardHandler)) + h := mw(next) + + tracker := &bodyTracker{Reader: strings.NewReader(`{"node_key":"some-body-content"}`)} + req := httptest.NewRequest(http.MethodPost, "/api/osquery/log", nil) + req.Body = tracker + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, tc.wantNextCalled, nextCalled, "next-called") + assert.Equal(t, tc.wantStatus, rec.Code, "status") + assert.Equal(t, tc.wantBodyRead, tracker.wasRead(), "body-read") + + if tc.wantPreAuthedInCtx { + require.NotNil(t, ctxFromNext) + assert.True(t, osqueryauth.IsPreAuthed(ctxFromNext)) + gotHost, ok := hostctx.FromContext(ctxFromNext) + assert.True(t, ok, "host should be in ctx") + if ok { + assert.Equal(t, host.ID, gotHost.ID) + } + } + }) + } +} + +// TestOsqueryHeaderPreAuthHostIdentityCert verifies that when the host has +// HasHostIdentityCert=true but the request lacks a valid HTTP message +// signature (httpsig.FromContext returns false), the pre-auth rejects with +// 401. This guards against a future refactor that accidentally bypasses +// VerifyHostIdentity on the header-auth path. +func TestOsqueryHeaderPreAuthHostIdentityCert(t *testing.T) { + const goodNodeKey = "valid-node-key" + // Host has an identity cert, so AuthenticateHost MUST verify the + // httpsig — and without a cert in ctx that verification fails. + host := &fleet.Host{ + ID: 42, + Hostname: "tpm-host", + HasHostIdentityCert: new(true), + OsqueryHostID: new("tpm-host-uuid"), + } + + ds := new(mock.Store) + svc, _ := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } + ds.LoadHostByNodeKeyFunc = func(ctx context.Context, nodeKey string) (*fleet.Host, error) { + if nodeKey == goodNodeKey { + return host, nil + } + return nil, newNotFoundError() + } + + var nextCalled bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + mw := osqueryHeaderPreAuth(svc, slog.New(slog.DiscardHandler)) + h := mw(next) + + req := httptest.NewRequest(http.MethodPost, "/api/osquery/log", strings.NewReader("{}")) + req.Header.Set("Authorization", "NodeKey "+goodNodeKey) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.False(t, nextCalled, "downstream handler must not run when httpsig verification fails") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + // Pre-auth normalizes every authentication failure (missing header, + // wrong scheme, invalid token, httpsig failure, etc.) into a uniform + // node_invalid:true response so the underlying reason is not exposed + // to clients. + assert.Contains(t, rec.Body.String(), `"node_invalid": true`) +} + +// TestOsqueryCarveBlockHeaderPreAuth covers the strict-mode behavior of +// /api/osquery/carve/block: the middleware is only registered when +// allow_body_auth_fallback is false, in which case all non-valid headers +// reject and valid headers populate hostctx so CarveBlock can enforce the +// ownership check. +func TestOsqueryCarveBlockHeaderPreAuth(t *testing.T) { + const goodNodeKey = "valid-node-key" + host := &fleet.Host{ID: 42, Hostname: "test-host", HasHostIdentityCert: new(false)} + + newSvc := func(t *testing.T) fleet.Service { + ds := new(mock.Store) + svc, _ := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.LoadHostByNodeKeyFunc = func(ctx context.Context, nodeKey string) (*fleet.Host, error) { + if nodeKey == goodNodeKey { + return host, nil + } + return nil, newNotFoundError() + } + return svc + } + + cases := []struct { + name string + authHeader string + wantNextCalled bool + wantStatus int + wantBodyRead bool + wantHostInCtx bool + }{ + {"absent header rejects", "", false, http.StatusUnauthorized, false, false}, + {"wrong scheme rejects", "Bearer " + goodNodeKey, false, http.StatusUnauthorized, false, false}, + {"malformed header rejects", "NodeKey", false, http.StatusUnauthorized, false, false}, + {"valid header passes through with host in ctx", "NodeKey " + goodNodeKey, true, http.StatusOK, false, true}, + {"invalid token short-circuits", "NodeKey bogus", false, http.StatusUnauthorized, false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newSvc(t) + + var nextCalled bool + var ctxFromNext context.Context + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + ctxFromNext = r.Context() + w.WriteHeader(http.StatusOK) + }) + + h := osqueryCarveBlockHeaderPreAuth(svc, slog.New(slog.DiscardHandler))(next) + + tracker := &bodyTracker{Reader: strings.NewReader(`{"session_id":"s","request_id":"r","data":""}`)} + req := httptest.NewRequest(http.MethodPost, "/api/osquery/carve/block", nil) + req.Body = tracker + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, tc.wantNextCalled, nextCalled, "next-called") + assert.Equal(t, tc.wantStatus, rec.Code, "status") + assert.Equal(t, tc.wantBodyRead, tracker.wasRead(), "body-read") + + if tc.wantNextCalled { + require.NotNil(t, ctxFromNext) + // Pre-authed node key ctx marker is never set on the + // carve/block path — we don't want authenticatedHost (which + // runs on other routes) to passthrough if wiring ever changes. + ok := osqueryauth.IsPreAuthed(ctxFromNext) + assert.False(t, ok, "pre-authed node key must not be set on carve/block path") + + gotHost, hostOk := hostctx.FromContext(ctxFromNext) + assert.Equal(t, tc.wantHostInCtx, hostOk, "host in ctx") + if hostOk { + assert.Equal(t, host.ID, gotHost.ID) + } + } + }) + } +} + +// TestAuthenticatedHostPreAuthedPassthrough verifies that when the HTTP +// pre-auth middleware has set the pre-auth marker AND the host in ctx, the +// endpoint-layer authenticatedHost middleware skips body-based auth entirely +// and does not call svc.AuthenticateHost again. +func TestAuthenticatedHostPreAuthedPassthrough(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + var loadCalled int32 + ds.LoadHostByNodeKeyFunc = func(ctx context.Context, nodeKey string) (*fleet.Host, error) { + atomic.AddInt32(&loadCalled, 1) + return nil, errors.New("should not be called on pre-authed path") + } + + var nextCalled bool + endpoint := authenticatedHost( + svc, + slog.New(slog.DiscardHandler), + func(ctx context.Context, request any) (any, error) { + nextCalled = true + return nil, nil + }, + ) + + preCtx := hostctx.NewContext(ctx, &fleet.Host{ID: 7}) + preCtx = osqueryauth.NewPreAuthedContext(preCtx) + _, err := endpoint(preCtx, &testNodeKeyRequest{NodeKey: ""}) // empty body key is fine + require.NoError(t, err) + assert.True(t, nextCalled, "next should be called") + assert.Equal(t, int32(0), atomic.LoadInt32(&loadCalled), "LoadHostByNodeKey must not be called when pre-authed") +} + +// TestAuthenticatedHostPreAuthedWithoutHostFails verifies the invariant that +// the pre-auth marker must always be set together with a host in ctx. If a +// future bug stamps the marker without populating hostctx, the endpoint-layer +// passthrough fails loudly instead of degrading silently. +func TestAuthenticatedHostPreAuthedWithoutHostFails(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } + + var nextCalled bool + endpoint := authenticatedHost( + svc, + slog.New(slog.DiscardHandler), + func(ctx context.Context, request any) (any, error) { + nextCalled = true + return nil, nil + }, + ) + + // Marker without host — programmer error. + preCtx := osqueryauth.NewPreAuthedContext(ctx) + _, err := endpoint(preCtx, &testNodeKeyRequest{NodeKey: ""}) + require.Error(t, err) + assert.False(t, nextCalled, "next must not be called when invariant violated") + assert.Contains(t, err.Error(), "pre-auth marker") +}