diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index b6d511e746..1afe4eb069 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1076,7 +1076,7 @@ the way that the Fleet server works. var httpSigVerifier func(http.Handler) http.Handler if license.IsPremium() { - httpSigVerifier, err = httpsig.Middleware(ds, kitlog.With(logger, "component", "http-sig-verifier")) + httpSigVerifier, err = httpsig.Middleware(ds, config.Auth.RequireHTTPMessageSignature, kitlog.With(logger, "component", "http-sig-verifier")) if err != nil { initFatal(err, "initializing HTTP signature verifier") } diff --git a/ee/server/integrationtest/hostidentity/hostidentity_requiresig_test.go b/ee/server/integrationtest/hostidentity/hostidentity_requiresig_test.go new file mode 100644 index 0000000000..fcc072b700 --- /dev/null +++ b/ee/server/integrationtest/hostidentity/hostidentity_requiresig_test.go @@ -0,0 +1,142 @@ +package hostidentity + +import ( + "bytes" + "crypto/elliptic" + "encoding/json" + "net/http" + "testing" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/service/contract" + "github.com/stretchr/testify/require" +) + +func TestHostIdentityRequireSignature(t *testing.T) { + // Set up suite with requireSignature = true + s := SetUpSuite(t, "integrationtest.HostIdentityRequireSignature", true) + + cases := []struct { + name string + fn func(t *testing.T, s *Suite) + }{ + {"OrbitEnrollAndConfig", testOrbitEnrollAndConfigWithRequiredSignature}, + {"OsqueryEnrollFailsWithoutSignature", testOsqueryEnrollFailsWithoutSignature}, + {"OrbitEnrollFailsWithoutSignature", testOrbitEnrollFailsWithoutSignature}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{ + "host_identity_scep_serials", "host_identity_scep_certificates", + }...) + c.fn(t, s) + }) + } +} + +func testOrbitEnrollAndConfigWithRequiredSignature(t *testing.T, s *Suite) { + // Get certificate using shared function from hostidentity_test.go + cert, eccPrivateKey := testGetCertWithCurve(t, s, elliptic.P384()) + + // Test enrollment first WITHOUT signature (should fail) + enrollRequest := contract.EnrollOrbitRequest{ + EnrollSecret: testEnrollmentSecret, + HardwareUUID: "test-uuid-" + cert.Subject.CommonName, + HardwareSerial: "test-serial-" + cert.Subject.CommonName, + Hostname: "test-hostname-" + cert.Subject.CommonName, + OsqueryIdentifier: cert.Subject.CommonName, + } + + // Test without signature first (should fail) + s.Do(t, "POST", "/api/fleet/orbit/enroll", enrollRequest, http.StatusUnauthorized) + + // Now test with signature (should succeed) + reqBody, err := json.Marshal(enrollRequest) + require.NoError(t, err) + + req, err := http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/enroll", bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + // Create signer using the shared helper from hostidentity_test.go + signer := createHTTPSigner(t, eccPrivateKey, cert) + + // Sign the request + err = signer.Sign(req) + require.NoError(t, err) + + // Send the signed request + client := fleethttp.NewClient() + httpResp, err := client.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + // The request with a valid HTTP signature should succeed + require.Equal(t, http.StatusOK, httpResp.StatusCode, "Orbit enrollment with HTTP signature should succeed") + + // Parse the response + var enrollResp enrollOrbitResponse + err = json.NewDecoder(httpResp.Body).Decode(&enrollResp) + require.NoError(t, err) + require.NotEmpty(t, enrollResp.OrbitNodeKey, "Should receive orbit node key") + require.NoError(t, enrollResp.Err) + + // Test config endpoint without signature (should fail) + configReq := orbitConfigRequest{OrbitNodeKey: enrollResp.OrbitNodeKey} + s.Do(t, "POST", "/api/fleet/orbit/config", configReq, http.StatusUnauthorized) + + // Test config endpoint with signature (should succeed) + reqBody, err = json.Marshal(configReq) + require.NoError(t, err) + + req, err = http.NewRequest("POST", s.Server.URL+"/api/fleet/orbit/config", bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + err = signer.Sign(req) + require.NoError(t, err) + + httpResp, err = client.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + require.Equal(t, http.StatusOK, httpResp.StatusCode, "Config request with HTTP signature should succeed") +} + +func testOsqueryEnrollFailsWithoutSignature(t *testing.T, s *Suite) { + // Test osquery enrollment without signature (should fail) + enrollRequest := contract.EnrollOsqueryAgentRequest{ + EnrollSecret: testEnrollmentSecret, + HostIdentifier: "osquery-enroll-without-signature-test", + HostDetails: map[string]map[string]string{ + "osquery_info": { + "version": "5.0.0", + }, + }, + } + + // Send request without HTTP signature (should fail) + // Use Do instead of DoJSON since the server returns HTML error pages + s.Do(t, "POST", "/api/v1/osquery/enroll", enrollRequest, http.StatusUnauthorized) + + // Also test the alternative osquery enroll endpoint + s.Do(t, "POST", "/api/osquery/enroll", enrollRequest, http.StatusUnauthorized) +} + +func testOrbitEnrollFailsWithoutSignature(t *testing.T, s *Suite) { + identifier := "orbit-enroll-without-signature-test" + // Test orbit enrollment without signature (should fail) + enrollRequest := contract.EnrollOrbitRequest{ + EnrollSecret: testEnrollmentSecret, + HardwareUUID: "test-uuid-" + identifier, + HardwareSerial: "test-serial-" + identifier, + Hostname: "test-hostname-" + identifier, + OsqueryIdentifier: identifier, + } + + // Send request without HTTP signature (should fail) + // Use Do instead of DoJSON since the server returns HTML error pages + s.Do(t, "POST", "/api/fleet/orbit/enroll", enrollRequest, http.StatusUnauthorized) +} diff --git a/ee/server/integrationtest/hostidentity/hostidentity_test.go b/ee/server/integrationtest/hostidentity/hostidentity_test.go index 01753d8221..f04d20fd2d 100644 --- a/ee/server/integrationtest/hostidentity/hostidentity_test.go +++ b/ee/server/integrationtest/hostidentity/hostidentity_test.go @@ -31,7 +31,7 @@ import ( const testEnrollmentSecret = "test_secret" func TestHostIdentity(t *testing.T) { - s := SetUpSuite(t, "integrationtest.HostIdentity") + s := SetUpSuite(t, "integrationtest.HostIdentity", false) cases := []struct { name string @@ -217,11 +217,6 @@ func createHTTPSigner(t *testing.T, eccPrivateKey *ecdsa.PrivateKey, cert *x509. func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPrivateKey *ecdsa.PrivateKey) { // Test orbit enrollment with the certificate - type EnrollOrbitResponse struct { - OrbitNodeKey string `json:"orbit_node_key,omitempty"` - Err error `json:"error,omitempty"` - } - enrollRequest := contract.EnrollOrbitRequest{ EnrollSecret: testEnrollmentSecret, HardwareUUID: "test-uuid-" + cert.Subject.CommonName, @@ -231,7 +226,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv } // This request is sent without an HTTP signature, so it should fail. - var enrollResp EnrollOrbitResponse + var enrollResp enrollOrbitResponse s.DoJSON(t, "POST", "/api/fleet/orbit/enroll", enrollRequest, http.StatusUnauthorized, &enrollResp) // Now send the same request with an HTTP signature @@ -259,7 +254,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv require.Equal(t, http.StatusOK, httpResp.StatusCode, "Request with HTTP signature should succeed") // Parse the response - var signedEnrollResp EnrollOrbitResponse + var signedEnrollResp enrollOrbitResponse err = json.NewDecoder(httpResp.Body).Decode(&signedEnrollResp) require.NoError(t, err) require.NotEmpty(t, signedEnrollResp.OrbitNodeKey, "Should receive orbit node key") @@ -271,7 +266,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv defer httpResp.Body.Close() require.Equal(t, http.StatusOK, httpResp.StatusCode, "Same request with HTTP signature should succeed") // Parse the response - signedEnrollResp = EnrollOrbitResponse{} + signedEnrollResp = enrollOrbitResponse{} err = json.NewDecoder(httpResp.Body).Decode(&signedEnrollResp) require.NoError(t, err) require.NotEmpty(t, signedEnrollResp.OrbitNodeKey, "Should receive orbit node key") @@ -279,9 +274,6 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv // Test /api/fleet/orbit/config endpoint with different signature scenarios t.Run("config endpoint signature tests", func(t *testing.T) { - type configRequest struct { - OrbitNodeKey string `json:"orbit_node_key"` - } testCases := []struct { name string @@ -291,7 +283,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv { name: "without signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} + configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -308,7 +300,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv { name: "with valid signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} + configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -330,7 +322,7 @@ func testOrbitEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPriv { name: "with corrupted signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} + configReq := orbitConfigRequest{OrbitNodeKey: signedEnrollResp.OrbitNodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -424,9 +416,6 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr // Test /api/osquery/config endpoint with different signature scenarios t.Run("osquery config endpoint signature tests", func(t *testing.T) { - type configRequest struct { - NodeKey string `json:"node_key"` - } testCases := []struct { name string @@ -436,7 +425,7 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr { name: "without signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{NodeKey: enrollResp.NodeKey} + configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -453,7 +442,7 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr { name: "with valid signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{NodeKey: enrollResp.NodeKey} + configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -475,7 +464,7 @@ func testOsqueryEnrollment(t *testing.T, s *Suite, cert *x509.Certificate, eccPr { name: "with corrupted signature", setupRequest: func() (*http.Request, error) { - configReq := configRequest{NodeKey: enrollResp.NodeKey} + configReq := osqueryConfigRequest{NodeKey: enrollResp.NodeKey} reqBody, err := json.Marshal(configReq) if err != nil { return nil, err @@ -783,10 +772,7 @@ func testWrongCertAuthentication(t *testing.T, s *Suite) { require.Equal(t, http.StatusOK, httpResp.StatusCode, "Enrollment with correct certificate should succeed") - type EnrollOrbitResponse struct { - OrbitNodeKey string `json:"orbit_node_key"` - } - var enrollResp EnrollOrbitResponse + var enrollResp enrollOrbitResponse err = json.NewDecoder(httpResp.Body).Decode(&enrollResp) require.NoError(t, err) require.NotEmpty(t, enrollResp.OrbitNodeKey) @@ -850,7 +836,7 @@ func testWrongCertAuthentication(t *testing.T, s *Suite) { require.Equal(t, http.StatusOK, httpResp.StatusCode, "Enrollment with correct certificate should succeed") - enrollResp = EnrollOrbitResponse{} + enrollResp = enrollOrbitResponse{} err = json.NewDecoder(httpResp.Body).Decode(&enrollResp) require.NoError(t, err) require.NotEmpty(t, enrollResp.OrbitNodeKey) diff --git a/ee/server/integrationtest/hostidentity/suite.go b/ee/server/integrationtest/hostidentity/suite.go index dd6ed5f39b..f17bf83c44 100644 --- a/ee/server/integrationtest/hostidentity/suite.go +++ b/ee/server/integrationtest/hostidentity/suite.go @@ -12,11 +12,27 @@ import ( "github.com/stretchr/testify/require" ) +// enrollOrbitResponse is the response structure for orbit enrollment +type enrollOrbitResponse struct { + OrbitNodeKey string `json:"orbit_node_key,omitempty"` + Err error `json:"error,omitempty"` +} + +// orbitConfigRequest is used for orbit config endpoint requests +type orbitConfigRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` +} + +// osqueryConfigRequest is used for osquery config endpoint requests +type osqueryConfigRequest struct { + NodeKey string `json:"node_key"` +} + type Suite struct { integrationtest.BaseSuite } -func SetUpSuite(t *testing.T, uniqueTestName string) *Suite { +func SetUpSuite(t *testing.T, uniqueTestName string, requireSignature bool) *Suite { // Note: t.Parallel() is called when MySQL datastore options are processed license := &fleet.LicenseInfo{ Tier: fleet.TierPremium, @@ -28,10 +44,13 @@ func SetUpSuite(t *testing.T, uniqueTestName string) *Suite { hostIdentitySCEPDepot, err := ds.NewHostIdentitySCEPDepot(kitlog.With(logger, "component", "host-id-scep-depot")) require.NoError(t, err) users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{ - License: license, - FleetConfig: &fleetCfg, - Logger: logger, - HostIdentitySCEPStorage: hostIdentitySCEPDepot, + License: license, + FleetConfig: &fleetCfg, + Logger: logger, + HostIdentity: &service.HostIdentity{ + SCEPStorage: hostIdentitySCEPDepot, + RequireHTTPMessageSignature: requireSignature, + }, }) s := &Suite{ diff --git a/ee/server/service/hostidentity/httpsig/middleware.go b/ee/server/service/hostidentity/httpsig/middleware.go index af2c65c2b0..3979d42eab 100644 --- a/ee/server/service/hostidentity/httpsig/middleware.go +++ b/ee/server/service/hostidentity/httpsig/middleware.go @@ -32,7 +32,7 @@ func FromContext(ctx context.Context) (types.HostIdentityCertificate, bool) { // to it, and then calls the handler passed as parameter to the MiddlewareFunc. type MiddlewareFunc func(http.Handler) http.Handler -func Middleware(ds fleet.Datastore, logger kitlog.Logger) (MiddlewareFunc, error) { +func Middleware(ds fleet.Datastore, requireSignature bool, logger kitlog.Logger) (MiddlewareFunc, error) { // Initialize HTTP signature verifier httpSig := NewHTTPSig(ds, logger) verifier, err := httpSig.Verifier() @@ -55,6 +55,12 @@ func Middleware(ds fleet.Datastore, logger kitlog.Logger) (MiddlewareFunc, error // If the request does not have an HTTP message signature, we do not verify it AND // we do not set the host identity cert in the context if req.Header.Get("signature") == "" || req.Header.Get("signature-input") == "" { + if requireSignature { + handleError(req.Context(), w, + ctxerr.Errorf(req.Context(), "missing required HTTP message signature: path=%s", req.URL.Path), + http.StatusUnauthorized) + return + } next.ServeHTTP(w, req) return } @@ -70,14 +76,13 @@ func Middleware(ds fleet.Datastore, logger kitlog.Logger) (MiddlewareFunc, error keySpecer, ok := result.KeySpecer.(*KeySpecer) if !ok { handleError(req.Context(), w, - ctxerr.New(req.Context(), fmt.Sprintf("could not extract host identity certificate key: path=%s", req.URL.Path)), + ctxerr.Errorf(req.Context(), "could not extract host identity certificate key: path=%s", req.URL.Path), http.StatusInternalServerError) return } if !result.Verified { handleError(req.Context(), w, - ctxerr.New(req.Context(), fmt.Sprintf("request not verified: path=%s host_uuid=%s", req.URL.Path, - keySpecer.hostIdentityCert.CommonName)), + ctxerr.Errorf(req.Context(), "request not verified: path=%s host_uuid=%s", req.URL.Path, keySpecer.hostIdentityCert.CommonName), http.StatusUnauthorized) return } diff --git a/server/config/config.go b/server/config/config.go index 5504b6a9db..bffba77058 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -137,11 +137,12 @@ func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handl return server } -// AuthConfig defines configs related to user authorization +// AuthConfig defines configs related to user or host authorization type AuthConfig struct { - BcryptCost int `yaml:"bcrypt_cost"` - SaltKeySize int `yaml:"salt_key_size"` - SsoSessionValidityPeriod time.Duration `yaml:"sso_session_validity_period"` + BcryptCost int `yaml:"bcrypt_cost"` + SaltKeySize int `yaml:"salt_key_size"` + SsoSessionValidityPeriod time.Duration `yaml:"sso_session_validity_period"` + RequireHTTPMessageSignature bool `yaml:"require_http_message_signature"` } // AppConfig defines configs related to HTTP @@ -1112,6 +1113,8 @@ func (man Manager) addConfigs() { "Size of salt for passwords") man.addConfigDuration("auth.sso_session_validity_period", 5*time.Minute, "Timeout from SSO start to SSO callback") + man.addConfigBool("auth.require_http_message_signature", false, + "Require HTTP message signatures for fleetd requests (Premium feature)") // App man.addConfigString("app.token_key", "CHANGEME", @@ -1531,9 +1534,10 @@ func (man Manager) LoadConfig() FleetConfig { VPPVerifyRequestDelay: man.getConfigDuration("server.vpp_verify_request_delay"), }, Auth: AuthConfig{ - BcryptCost: man.getConfigInt("auth.bcrypt_cost"), - SaltKeySize: man.getConfigInt("auth.salt_key_size"), - SsoSessionValidityPeriod: man.getConfigDuration("auth.sso_session_validity_period"), + BcryptCost: man.getConfigInt("auth.bcrypt_cost"), + SaltKeySize: man.getConfigInt("auth.salt_key_size"), + SsoSessionValidityPeriod: man.getConfigDuration("auth.sso_session_validity_period"), + RequireHTTPMessageSignature: man.getConfigBool("auth.require_http_message_signature"), }, App: AppConfig{ TokenKeySize: man.getConfigInt("app.token_key_size"), @@ -2050,9 +2054,10 @@ func TestConfig() FleetConfig { InviteTokenValidityPeriod: 5 * 24 * time.Hour, }, Auth: AuthConfig{ - BcryptCost: 6, // Low cost keeps tests fast - SaltKeySize: 24, - SsoSessionValidityPeriod: 5 * time.Minute, + BcryptCost: 6, // Low cost keeps tests fast + SaltKeySize: 24, + SsoSessionValidityPeriod: 5 * time.Minute, + RequireHTTPMessageSignature: false, }, Session: SessionConfig{ KeySize: 64, diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 2899d52355..934a8f6366 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -331,6 +331,12 @@ func (svc *mockMailService) CanSendEmail(smtpSettings fleet.SMTPSettings) bool { type TestNewScheduleFunc func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc +// HostIdentity combines host identity-related test options +type HostIdentity struct { + SCEPStorage scep_depot.Depot + RequireHTTPMessageSignature bool +} + type TestServerOpts struct { Logger kitlog.Logger License *fleet.LicenseInfo @@ -365,7 +371,7 @@ type TestServerOpts struct { DigiCertService fleet.DigiCertService EnableSCIM bool ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy - HostIdentitySCEPStorage scep_depot.Depot + HostIdentity *HostIdentity } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) { @@ -460,10 +466,10 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl var extra []ExtraHandlerOption extra = append(extra, WithLoginRateLimit(throttled.PerMin(1000))) - if len(opts) > 0 && opts[0].HostIdentitySCEPStorage != nil { - require.NoError(t, hostidentity.RegisterSCEP(rootMux, opts[0].HostIdentitySCEPStorage, ds, logger)) + if len(opts) > 0 && opts[0].HostIdentity != nil { + require.NoError(t, hostidentity.RegisterSCEP(rootMux, opts[0].HostIdentity.SCEPStorage, ds, logger)) var httpSigVerifier func(http.Handler) http.Handler - httpSigVerifier, err := httpsig.Middleware(ds, kitlog.With(logger, "component", "http-sig-verifier")) + httpSigVerifier, err := httpsig.Middleware(ds, opts[0].HostIdentity.RequireHTTPMessageSignature, kitlog.With(logger, "component", "http-sig-verifier")) require.NoError(t, err) extra = append(extra, WithHTTPSigVerifier(httpSigVerifier)) }