diff --git a/frontend/pages/hosts/details/DeviceUserPage/ManualEnrollMdmModal/ManualEnrollMdmModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/ManualEnrollMdmModal/ManualEnrollMdmModal.tsx
index 7b525906f4..1920408bf2 100644
--- a/frontend/pages/hosts/details/DeviceUserPage/ManualEnrollMdmModal/ManualEnrollMdmModal.tsx
+++ b/frontend/pages/hosts/details/DeviceUserPage/ManualEnrollMdmModal/ManualEnrollMdmModal.tsx
@@ -51,8 +51,9 @@ const ManualEnrollMdmModal = ({
profile.
- Select Enroll then enter your password.
+ Select Install... then confirm again clicking Install.
+ Enter your password when you get a prompt.
Select Done to close this window and select Refetch on
your My device page to tell
your organization that MDM is on.
diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go
index f754b85620..601fe313b6 100644
--- a/pkg/mdm/mdmtest/apple.go
+++ b/pkg/mdm/mdmtest/apple.go
@@ -217,7 +217,7 @@ func (c *TestAppleMDMClient) Enroll() error {
}
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDesktopURL() error {
- return c.fetchEnrollmentProfile(
+ return c.fetchOTAProfile(
"/api/latest/fleet/device/" + c.desktopURLToken + "/mdm/apple/manual_enrollment_profile",
)
}
@@ -229,6 +229,53 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
}
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error {
+ return c.fetchOTAProfile(
+ "/api/latest/fleet/enrollment_profiles/ota?enroll_secret=" + url.QueryEscape(c.otaEnrollSecret),
+ )
+}
+
+func (c *TestAppleMDMClient) fetchOTAProfile(url string) error {
+ request, err := http.NewRequest("GET", c.fleetServerURL+url, nil)
+ if err != nil {
+ return fmt.Errorf("create request: %w", err)
+ }
+ // #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()
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
+ }
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return fmt.Errorf("read body: %w", err)
+ }
+
+ p7, err := pkcs7.Parse(body)
+ if err != nil {
+ return fmt.Errorf("OTA profile is not XML nor PKCS7 parseable: %w", err)
+ }
+ err = p7.Verify()
+ if err != nil {
+ return fmt.Errorf("verifying OTA profile: %w", err)
+ }
+
+ var otaEnrollmentProfile struct {
+ PayloadContent struct {
+ URL string `plist:"URL"`
+ } `plist:"PayloadContent"`
+ }
+ err = plist.Unmarshal(p7.Content, &otaEnrollmentProfile)
+ if err != nil {
+ return fmt.Errorf("unmarshaling OTA enrollment response: %w", err)
+ }
+
rawDeviceInfo := []byte(fmt.Sprintf(`
@@ -260,7 +307,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error {
request, err := http.NewRequest(
"POST",
- c.fleetServerURL+"/api/latest/fleet/ota_enrollment?enroll_secret="+c.otaEnrollSecret,
+ otaEnrollmentProfile.PayloadContent.URL,
bytes.NewReader(sig),
)
if err != nil {
@@ -297,7 +344,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error {
if err != nil {
return fmt.Errorf("creating mock certificates: %w", err)
}
- body, err := do(mockedCert, mockedKey)
+ body, err = do(mockedCert, mockedKey)
if err != nil {
return fmt.Errorf("first OTA request: %w", err)
}
@@ -326,7 +373,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromOTAURL() error {
if err != nil {
return fmt.Errorf("seconde OTA request: %w", err)
}
- p7, err := pkcs7.Parse(body)
+ p7, err = pkcs7.Parse(body)
if err != nil {
return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err)
}
diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go
index 7943068687..c545c898cc 100644
--- a/server/datastore/mysql/apple_mdm.go
+++ b/server/datastore/mysql/apple_mdm.go
@@ -917,6 +917,7 @@ func createHostFromMDMDB(
tx sqlx.ExtContext,
logger log.Logger,
devices []hostToCreateFromMDM,
+ fromADE bool,
macOSTeam, iosTeam, ipadTeam *uint,
) (int64, []fleet.Host, error) {
// NOTE: order of arguments for teams is important, see statement.
@@ -981,6 +982,7 @@ func createHostFromMDMDB(
h.platform,
h.hardware_model,
h.hardware_serial,
+ h.hostname,
COALESCE(hmdm.enrolled, 0) as enrolled
FROM hosts h
LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id
@@ -1024,7 +1026,7 @@ func createHostFromMDMDB(
ctx,
tx,
appCfg.ServerSettings,
- true,
+ fromADE,
unmanagedHostIDs...,
); err != nil {
return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
@@ -1046,7 +1048,7 @@ func (ds *Datastore) IngestMDMAppleDeviceFromOTAEnrollment(
HardwareModel: deviceInfo.Product,
},
}
- _, _, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, teamID, teamID, teamID)
+ _, _, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, false, teamID, teamID, teamID)
return ctxerr.Wrap(ctx, err, "creating host from OTA enrollment")
})
}
@@ -1105,6 +1107,7 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(
tx,
ds.logger,
htc,
+ true,
teamIDs[0], teamIDs[1], teamIDs[2],
)
if err != nil {
diff --git a/server/fleet/errors.go b/server/fleet/errors.go
index 67c93003ee..2d3b53260b 100644
--- a/server/fleet/errors.go
+++ b/server/fleet/errors.go
@@ -279,6 +279,36 @@ func (e PermissionError) PermissionError() []map[string]string {
return forbidden
}
+// OTAForbiddenError is a special kind of forbidden error that intentionally
+// exposes information about the error so it can be shown in iPad/iPhone native
+// dialogs during OTA enrollment.
+//
+// I couldn't find any documentation but the way it works is:
+//
+// - if the response has a status code 403
+// - and the body has a `message` field
+//
+// the content of `message` will be displayed to the end user.
+type OTAForbiddenError struct {
+ ErrorWithUUID
+ InternalErr error
+}
+
+func (e OTAForbiddenError) Error() string {
+ return "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin."
+}
+
+func (e OTAForbiddenError) StatusCode() int {
+ return http.StatusForbidden
+}
+
+func (e OTAForbiddenError) Internal() string {
+ if e.InternalErr == nil {
+ return ""
+ }
+ return e.InternalErr.Error()
+}
+
// licenseError is returned when the application is not properly licensed.
type licenseError struct {
ErrorWithUUID
diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go
index 75634aca98..247c653617 100644
--- a/server/mdm/apple/mobileconfig/profiles.go
+++ b/server/mdm/apple/mobileconfig/profiles.go
@@ -1,9 +1,6 @@
package mobileconfig
import (
- "encoding/xml"
- "fmt"
- "strings"
"text/template"
)
@@ -119,13 +116,7 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro
`))
-var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.FuncMap{"xml": func(v string) (string, error) {
- var escaped strings.Builder
- if err := xml.EscapeText(&escaped, []byte(v)); err != nil {
- return "", fmt.Errorf("XML escaping in OTA profile: %w", err)
- }
- return escaped.String(), nil
-}}).Option("missingkey=error").Parse(`
+var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(funcMap).Option("missingkey=error").Parse(`
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index da2c75f68f..99bfa2195d 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -4204,8 +4204,8 @@ type mdmAppleOTARequest struct {
func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
enrollSecret := r.URL.Query().Get("enroll_secret")
if enrollSecret == "" {
- return nil, &fleet.BadRequestError{
- Message: "enroll_secret query parameter is required",
+ return nil, &fleet.OTAForbiddenError{
+ InternalErr: errors.New("enroll_secret query parameter was empty"),
}
}
@@ -4289,7 +4289,9 @@ func (svc *Service) MDMAppleProcessOTAEnrollment(
enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret)
if err != nil {
if fleet.IsNotFound(err) {
- return nil, authz.ForbiddenWithInternal("invalid enroll secret provided", nil, nil, nil)
+ return nil, &fleet.OTAForbiddenError{
+ InternalErr: err,
+ }
}
return nil, ctxerr.Wrap(ctx, err, "validating enroll secret")
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index ab072753f3..aeabb3542b 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -234,6 +234,19 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
func TestAppleMDMAuthorization(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
+ ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
+ return []*fleet.EnrollSecret{
+ {
+ Secret: "abcd",
+ TeamID: nil,
+ },
+ {
+ Secret: "efgh",
+ TeamID: nil,
+ },
+ }, nil
+ }
+
checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) {
t.Helper()
diff --git a/server/service/devices.go b/server/service/devices.go
index 288fbb304e..9eb1ba3a3a 100644
--- a/server/service/devices.go
+++ b/server/service/devices.go
@@ -3,8 +3,9 @@ package service
import (
"context"
"crypto/x509"
+ "database/sql"
"encoding/json"
- "fmt"
+ "errors"
"io"
"net/http"
"net/url"
@@ -529,34 +530,37 @@ func (svc *Service) GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) ([]b
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("forbidden: only device-authenticated hosts can access this endpoint"))
}
- appConfig, err := svc.ds.AppConfig(ctx)
+ cfg, err := svc.ds.AppConfig(ctx)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err)
+ return nil, ctxerr.Wrap(ctx, err, "fetching app config")
}
- topic, err := svc.mdmPushCertTopic(ctx)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
+ host, ok := hostctx.FromContext(ctx)
+ if !ok {
+ return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
}
- assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
- fleet.MDMAssetSCEPChallenge,
- })
- if err != nil {
- return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err)
+ tmSecrets, err := svc.ds.GetEnrollSecrets(ctx, host.TeamID)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, ctxerr.Wrap(ctx, err, "getting host team enroll secrets")
+ }
+ if len(tmSecrets) == 0 && host.TeamID != nil {
+ tmSecrets, err = svc.ds.GetEnrollSecrets(ctx, nil)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, ctxerr.Wrap(ctx, err, "getting no team enroll secrets")
+ }
+ }
+ if len(tmSecrets) == 0 {
+ return nil, &fleet.BadRequestError{Message: "unable to find an enroll secret to generate enrollment profile"}
}
- enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
- appConfig.OrgInfo.OrgName,
- appConfig.ServerSettings.ServerURL,
- string(assets[fleet.MDMAssetSCEPChallenge].Value),
- topic,
- )
+ enrollSecret := tmSecrets[0].Secret
+ profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile")
+ return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file for manual enrollment")
}
- signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds)
+ signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "signing profile")
}
diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go
index e54bd9bf8a..f7fc2e2373 100644
--- a/server/service/integration_mdm_profiles_test.go
+++ b/server/service/integration_mdm_profiles_test.go
@@ -4198,10 +4198,6 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
)
}
-func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
- s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual")
-}
-
func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() {
t := s.T()
ctx := context.Background()
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 61d25d5459..2e7ef1f152 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -556,6 +556,14 @@ func (s *integrationMDMTestSuite) SetupSuite() {
// enable MDM flows
s.appleCoreCertsSetup()
+ // create a global enroll secret
+ var applyResp applyEnrollSecretSpecResponse
+ s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
+ Spec: &fleet.EnrollSecretSpec{
+ Secrets: []*fleet.EnrollSecret{{Secret: "global-secret"}},
+ },
+ }, http.StatusOK, &applyResp)
+
s.T().Cleanup(fleetdmSrv.Close)
s.T().Cleanup(s.appleVPPConfigSrv.Close)
s.T().Cleanup(s.appleITunesSrv.Close)
@@ -1045,6 +1053,7 @@ func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testi
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
+ HardwareModel: "MacBookPro16,1",
UUID: mdmDevice.UUID,
HardwareSerial: mdmDevice.SerialNumber,
@@ -1127,7 +1136,7 @@ func (s *integrationMDMTestSuite) TestDeviceMDMManualEnroll() {
s.DoRaw("GET", "/api/latest/fleet/device/invalid_token/mdm/apple/manual_enrollment_profile", nil, http.StatusUnauthorized)
// valid token downloads the profile
- s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/device/" + token + "/mdm/apple/manual_enrollment_profile")
+ s.downloadAndVerifyOTAEnrollmentProfile("/api/latest/fleet/device/" + token + "/mdm/apple/manual_enrollment_profile")
}
func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
@@ -5497,6 +5506,45 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
return s.verifyEnrollmentProfile(body, "")
}
+func (s *integrationMDMTestSuite) downloadAndVerifyOTAEnrollmentProfile(path string) {
+ t := s.T()
+
+ resp := s.DoRaw("GET", path, nil, http.StatusOK)
+ rawProfile, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ require.NoError(t, err)
+ require.Contains(t, resp.Header, "Content-Disposition")
+ require.Contains(t, resp.Header, "Content-Type")
+ require.Contains(t, resp.Header, "X-Content-Type-Options")
+ require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
+ require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
+ headerLen, err := strconv.Atoi(resp.Header.Get("Content-Length"))
+ require.NoError(t, err)
+ require.Equal(t, len(rawProfile), headerLen)
+
+ p7, err := pkcs7.Parse(rawProfile)
+ require.NoError(t, err)
+ rootCA := x509.NewCertPool()
+
+ assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{
+ fleet.MDMAssetCACert,
+ })
+ require.NoError(t, err)
+
+ require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value))
+ require.NoError(t, p7.VerifyWithChain(rootCA))
+
+ var otaEnrollmentProfile struct {
+ PayloadContent struct {
+ URL string `plist:"URL"`
+ } `plist:"PayloadContent"`
+ }
+ err = plist.Unmarshal(p7.Content, &otaEnrollmentProfile)
+ require.NoError(t, err)
+ require.Contains(t, otaEnrollmentProfile.PayloadContent.URL, s.getConfig().ServerSettings.ServerURL+"/api/v1/fleet/ota_enrollment")
+}
+
func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enrollmentRef string) *enrollmentProfile {
t := s.T()
var profile enrollmentProfile
@@ -9828,7 +9876,10 @@ func (s *integrationMDMTestSuite) TestAPNsPushCron() {
defer func() { s.pushProvider.PushFunc = originalPushMock }()
var recordedPushes []*mdm.Push
+ var mu sync.Mutex
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ mu.Lock()
+ defer mu.Unlock()
recordedPushes = pushes
return mockSuccessfulPush(pushes)
}
@@ -11149,7 +11200,7 @@ func (s *integrationMDMTestSuite) TestEnrollmentProfilesWithSpecialChars() {
// manual enrollment from My Device
token := "token_test_manual_enroll"
createHostAndDeviceToken(t, s.ds, token)
- s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/device/" + token + "/mdm/apple/manual_enrollment_profile")
+ s.downloadAndVerifyOTAEnrollmentProfile("/api/latest/fleet/device/" + token + "/mdm/apple/manual_enrollment_profile")
// automatic enrollment by token
rawMsg := json.RawMessage(`{"allow_pairing": true}`)
@@ -11209,9 +11260,9 @@ func (s *integrationMDMTestSuite) TestOTAEnrollment() {
`)
// request with no enroll secret
- httpResp := s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment", reqBody, http.StatusBadRequest)
+ httpResp := s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment", reqBody, http.StatusForbidden)
errMsg := extractServerErrorText(httpResp.Body)
- require.Contains(t, errMsg, "enroll_secret query parameter is required")
+ require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.")
require.NoError(t, httpResp.Body.Close())
// request with no body
@@ -11237,14 +11288,14 @@ func (s *integrationMDMTestSuite) TestOTAEnrollment() {
// request with invalid apple signature
httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden)
errMsg = extractServerErrorText(httpResp.Body)
- require.Contains(t, errMsg, "forbidden")
+ require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.")
require.NoError(t, httpResp.Body.Close())
// request with invalid device signature
os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1")
httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden)
errMsg = extractServerErrorText(httpResp.Body)
- require.Contains(t, errMsg, "forbidden")
+ require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.")
require.NoError(t, httpResp.Body.Close())
// request without serial number