From 20c0728ac92b5ed08f264c756d1352dc77bf8091 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:26:14 -0600 Subject: [PATCH 1/4] Apply minimum OS version enforcement to MDM SSO endpoint --- changes/22361-os-update-ade-sso | 2 ++ cmd/fleet/serve.go | 3 +++ server/service/handler.go | 42 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 changes/22361-os-update-ade-sso diff --git a/changes/22361-os-update-ade-sso b/changes/22361-os-update-ade-sso new file mode 100644 index 000000000000..40221866fb93 --- /dev/null +++ b/changes/22361-os-update-ade-sso @@ -0,0 +1,2 @@ +- Fixed issue where minimum OS version enforcement was not being applied during Apple ADE if MDM + IdP integration was enabled. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 4c836bebdccd..0504891acc7a 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1027,6 +1027,9 @@ the way that the Fleet server works. "get_frontend", service.ServeFrontend(config.Server.URLPrefix, config.Server.SandboxEnabled, httpLogger), ) + + frontendHandler = service.WithMDMEnrollmentMiddleware(svc, httpLogger, frontendHandler, config.Server.URLPrefix) + apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore) setupRequired, err := svc.SetupRequired(baseCtx) diff --git a/server/service/handler.go b/server/service/handler.go index 4f916f576ba2..535ac29fc36f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -1225,3 +1226,44 @@ func registerMDM( mux.Handle(apple_mdm.MDMPath, mdmHandler) return nil } + +func WithMDMEnrollmentMiddleware(svc fleet.Service, logger kitlog.Logger, next http.Handler, urlPrefix string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mdm/sso" { + next.ServeHTTP(w, r) + return + } + + // if x-apple-aspen-deviceinfo custom header is present, we need to check for minimum os version + di := r.Header.Get("x-apple-aspen-deviceinfo") + if di != "" { + parsed, err := apple_mdm.ParseDeviceinfo(di, false) // FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879) + if err != nil { + // just log the error and continue to next + level.Error(logger).Log("msg", "parsing x-apple-aspen-deviceinfo", "err", err) + next.ServeHTTP(w, r) + return + } + + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(context.Background(), parsed) + if err != nil { + // just log the error and continue to next + level.Error(logger).Log("msg", "checking minimum os version for mdm", "err", err) + next.ServeHTTP(w, r) + return + } + + if sur != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + if err := json.NewEncoder(w).Encode(sur); err != nil { + level.Error(logger).Log("msg", "failed to encode software update required", "err", err) + http.Redirect(w, r, r.URL.String()+"?error=true", http.StatusSeeOther) + } + return + } + } + + next.ServeHTTP(w, r) + } +} From d1363ff28c4e86eb6e36b107e64f06ae55da0f7e Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:46:26 -0600 Subject: [PATCH 2/4] Update tests --- server/service/apple_mdm.go | 8 +- server/service/apple_mdm_test.go | 2 +- server/service/integration_mdm_dep_test.go | 495 ++++++++++++++++++++- server/service/integration_mdm_test.go | 1 + server/service/testing_utils.go | 6 + 5 files changed, 492 insertions(+), 20 deletions(-) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index aa6fcaf05a7a..7c29fc86a36b 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -1526,7 +1526,13 @@ func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.M return false, nil } - return apple_mdm.IsLessThanVersion(m.OSVersion, settings.MinimumVersion.Value) + needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, settings.MinimumVersion.Value) + if err != nil { + level.Info(svc.logger).Log("msg", "checking os updates settings, cannot compare versions", "serial", m.Serial, "current_version", m.OSVersion, "minimum_version", settings.MinimumVersion.Value) + return false, nil + } + + return needsUpdate, nil } func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 60ae723f9a7a..be4f75680bf2 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -4093,7 +4093,7 @@ func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { SoftwareUpdateDeviceID: "J516sAP", }, updateRequired: nil, - err: "invalid current version", + err: "", // no error, allow enrollment to proceed without software update }, } diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 50ec5112cc17..b8350fcd5ef9 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -1,7 +1,13 @@ package service import ( + "bytes" "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "io" @@ -10,18 +16,22 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" @@ -31,6 +41,7 @@ import ( micromdm "github.com/micromdm/micromdm/mdm/mdm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.mozilla.org/pkcs7" ) type profileAssignmentReq struct { @@ -1046,14 +1057,22 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { deletedSerial = devices[1].SerialNumber devices = []godep.Device{ {SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now()}, - {SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified", - OpDate: time.Now().Add(time.Second)}, - {SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", - OpDate: time.Now().Add(2 * time.Second)}, - {SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", - OpDate: time.Now().Add(3 * time.Second)}, - {SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", - OpDate: time.Now().Add(4 * time.Second)}, + { + SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified", + OpDate: time.Now().Add(time.Second), + }, + { + SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", + OpDate: time.Now().Add(2 * time.Second), + }, + { + SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", + OpDate: time.Now().Add(3 * time.Second), + }, + { + SerialNumber: addedModifiedDeletedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", + OpDate: time.Now().Add(4 * time.Second), + }, {SerialNumber: deletedAddedSerial, Model: "MacBook Pro", OS: "osx", OpType: "deleted", OpDate: time.Now()}, {SerialNumber: deletedAddedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now().Add(time.Second)}, @@ -1436,7 +1455,8 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignmentWithMultipleABMs() { RetryJobID uint `db:"retry_job_id"` } checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, - expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow { + expectedStatus fleet.DEPAssignProfileResponseStatus, + ) map[string]hostDEPRow { bySerial := make(map[string]hostDEPRow, len(deviceSerials)) for _, deviceSerial := range deviceSerials { mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -1627,14 +1647,18 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignmentWithMultipleABMs() { devices = []godep.Device{ {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro M1", OS: "osx", OpType: "added", OpDate: time.Now()}, {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "deleted", OpDate: time.Now()}, - {SerialNumber: defaultOrgDevices[1].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "added", - OpDate: time.Now().Add(time.Microsecond)}, + { + SerialNumber: defaultOrgDevices[1].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "added", + OpDate: time.Now().Add(time.Microsecond), + }, } defaultOrgDevices = []godep.Device{ {SerialNumber: defaultOrgDevices[0].SerialNumber, Model: "MacBook Mini M2", OS: "osx", OpType: "added", OpDate: time.Now()}, {SerialNumber: defaultOrgDevices[1].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "deleted", OpDate: time.Now()}, - {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "added", - OpDate: time.Now().Add(time.Microsecond)}, + { + SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "added", + OpDate: time.Now().Add(time.Microsecond), + }, } // trigger a profile sync @@ -1663,13 +1687,17 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignmentWithMultipleABMs() { // Delete the devices devices = []godep.Device{ {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro M1", OS: "osx", OpType: "modified", OpDate: time.Now()}, - {SerialNumber: devices[2].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "deleted", - OpDate: time.Now().Add(time.Microsecond)}, + { + SerialNumber: devices[2].SerialNumber, Model: "MacBook Pro M2", OS: "osx", OpType: "deleted", + OpDate: time.Now().Add(time.Microsecond), + }, } defaultOrgDevices = []godep.Device{ {SerialNumber: defaultOrgDevices[0].SerialNumber, Model: "MacBook Mini M2", OS: "osx", OpType: "modified", OpDate: time.Now()}, - {SerialNumber: defaultOrgDevices[2].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "deleted", - OpDate: time.Now().Add(time.Microsecond)}, + { + SerialNumber: defaultOrgDevices[2].SerialNumber, Model: "MacBook Mini M1", OS: "osx", OpType: "deleted", + OpDate: time.Now().Add(time.Microsecond), + }, } // trigger a profile sync @@ -1694,7 +1722,6 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignmentWithMultipleABMs() { checkHostDEPAssignProfileResponses(defaultSerials, defaultProfileUUIDs[len(defaultProfileUUIDs)-1], fleet.DEPAssignProfileResponseSuccess) checkHostDEPAssignProfileResponses(teamSerials, teamProfileUUIDs[len(teamProfileUUIDs)-1], fleet.DEPAssignProfileResponseSuccess) - } func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() { @@ -2342,3 +2369,435 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo require.Equal(t, 1, deviceConfiguredCount) require.Equal(t, 0, otherCount) } + +func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { + t := s.T() + s.enableABM(t.Name()) + + devices := []godep.Device{ + {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}, + {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}, + } + profileAssignmentReqs := []profileAssignmentReq{} + + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + err := encoder.Encode(map[string]string{"auth_session_token": "xyz"}) + require.NoError(t, err) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}) + require.NoError(t, err) + case "/server/devices": + // This endpoint is used to get an initial list of + // devices, return a single device + err := encoder.Encode(godep.DeviceResponse{Devices: devices}) + require.NoError(t, err) + case "/devices/sync": + // This endpoint is polled over time to sync devices from + // ABM, send a repeated serial and a new one + err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"}) + require.NoError(t, err) + case "/profile/devices": + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + var prof profileAssignmentReq + require.NoError(t, json.Unmarshal(b, &prof)) + profileAssignmentReqs = append(profileAssignmentReqs, prof) + var resp godep.ProfileResponse + resp.ProfileUUID = prof.ProfileUUID + resp.Devices = make(map[string]string, len(prof.Devices)) + for _, device := range prof.Devices { + resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) + } + err = encoder.Encode(resp) + require.NoError(t, err) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + + s.runDEPSchedule() + + listHostsRes := listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 2) + + token := loadEnrollmentProfileDEPToken(t, s.ds) + + encodeDeviceInfo := func(machineInfo fleet.MDMAppleMachineInfo) string { + body, err := plist.Marshal(machineInfo) + require.NoError(t, err) + + // body is expected to be a PKCS7 signed message, although we don't currently verify the signature + signedData, err := pkcs7.NewSignedData(body) + require.NoError(t, err) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + crtBytes, err := depot.NewCACert().SelfSign(rand.Reader, key.Public(), key) + require.NoError(t, err) + crt, err := x509.ParseCertificate(crtBytes) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(crt, key, pkcs7.SignerInfoConfig{})) + sig, err := signedData.Finish() + require.NoError(t, err) + + return base64.StdEncoding.EncodeToString(sig) + } + + fetchEnrollProfile := func(machineInfo *fleet.MDMAppleMachineInfo, expectEnrollInfo *mdmtest.AppleEnrollInfo, expectSoftwareUpdate *fleet.MDMAppleSoftwareUpdateRequiredDetails) error { + request, err := http.NewRequest("GET", s.server.URL+apple_mdm.EnrollPath+"?token="+token, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if machineInfo != nil { + request.Header.Set("x-apple-aspen-deviceinfo", encodeDeviceInfo(*machineInfo)) + } + + // #nosec (this client is used for testing only) + cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + })) + response, err := cc.Do(request) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer response.Body.Close() + + switch { + case expectEnrollInfo != nil: + require.Equal(t, http.StatusOK, response.StatusCode) + body, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + rawProfile := body + if !bytes.HasPrefix(rawProfile, []byte(" 0 && opts[0].WithDEPWebview { + frontendHandler := WithMDMEnrollmentMiddleware(svc, logger, ServeFrontend("", false, logger), "") + rootMux.Handle("/", frontendHandler) + } + apiHandler := MakeHandler(svc, cfg, logger, limitStore, WithLoginRateLimit(throttled.PerMin(1000))) rootMux.Handle("/api/", apiHandler) var errHandler *errorstore.Handler From 19bc9c954f787c71eb77e59d3322028515803bf3 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:25:09 -0600 Subject: [PATCH 3/4] Update tests --- server/service/integration_mdm_dep_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 09340fa81ac9..8f36fcada5d2 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -16,7 +16,6 @@ import ( "os" "path/filepath" "slices" - "strconv" "strings" "testing" "time" @@ -2769,7 +2768,7 @@ func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { } else { tcResp := teamResponse{} - s.DoJSON("PATCH", "/api/latest/fleet/teams/"+strconv.Itoa(int(*teamId)), json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_updates": { "minimum_version": "%s", "deadline": "2023-12-31" } } }`, minVersion)), http.StatusOK, &tcResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", *teamId), json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_updates": { "minimum_version": "%s", "deadline": "2023-12-31" } } }`, minVersion)), http.StatusOK, &tcResp) } } @@ -2913,6 +2912,11 @@ func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + if tc.updateRequired == nil { + // skip these test cases that depend more heavily on the frontent + // integration, we're just testing the error handling here + return + } var mi fleet.MDMAppleMachineInfo if tc.machineInfo != nil { mi = *tc.machineInfo @@ -2980,7 +2984,7 @@ func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { t.Run("sso enabled", func(t *testing.T) { tcResp := teamResponse{} - s.DoJSON("PATCH", "/api/latest/fleet/teams/"+strconv.Itoa(int(team.ID)), json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": true } } }`)), http.StatusOK, &tcResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": true } } }`)), http.StatusOK, &tcResp) acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -2989,6 +2993,11 @@ func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + if tc.updateRequired == nil { + // skip these test cases that depend more heavily on the frontent + // integration, we're just testing the error handling here + return + } if tc.machineInfo != nil { tc.machineInfo.Serial = devices[0].SerialNumber } From 9ffe86edbe349e9ab52248646931bb17f9e87df6 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:41:25 -0600 Subject: [PATCH 4/4] Fix lint --- server/service/integration_mdm_dep_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 8f36fcada5d2..53046106af53 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -2984,7 +2984,7 @@ func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() { t.Run("sso enabled", func(t *testing.T) { tcResp := teamResponse{} - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": true } } }`)), http.StatusOK, &tcResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), json.RawMessage(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": true } } }`), http.StatusOK, &tcResp) acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{