Remove webview when IdP not enabled. (#29283)

For #26996 and #28452

Demo video: https://www.youtube.com/shorts/WGS3JmKiZTs

The device/machine info is extracted from the PKCS7 signed body of the
POST request.

I did manual QA on iPhone since I don't have an ADE macOS device with
me.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky
2025-05-20 22:50:48 +03:00
committed by GitHub
parent 0e030eb458
commit 01b3a6e2d2
11 changed files with 162 additions and 59 deletions
@@ -0,0 +1 @@
During Apple MDM enrollment, skip webview popup when end user authentication is disabled
+61 -17
View File
@@ -67,6 +67,9 @@ type TestAppleMDMClient struct {
// fetchEnrollmentProfileFromDEP indicates whether this simulated device will fetch
// the enrollment profile from Fleet as if it were a device running the DEP flow.
fetchEnrollmentProfileFromDEP bool
// fetchEnrollmentProfileFromDEPUsingPost functions the same as fetchEnrollmentProfileFromDEP
// except that it uses a POST request instead of a GET request.
fetchEnrollmentProfileFromDEPUsingPost bool
// fetchEnrollmentProfileFromOTA indicates whether this simulated device will fetch
// the enrollment profile from Fleet as if it were a device running the OTA flow.
@@ -96,6 +99,13 @@ func TestMDMAppleClientDebug() TestMDMAppleClientOption {
}
}
func WithEnrollmentProfileFromDEPUsingPost() TestMDMAppleClientOption {
return func(c *TestAppleMDMClient) {
c.fetchEnrollmentProfileFromDEPUsingPost = true
c.fetchEnrollmentProfileFromDEP = false
}
}
// AppleEnrollInfo contains the necessary information to enroll to an MDM server.
type AppleEnrollInfo struct {
// SCEPChallenge is the SCEP challenge to present to the SCEP server when enrolling.
@@ -195,6 +205,10 @@ func (c *TestAppleMDMClient) Enroll() error {
if err := c.fetchEnrollmentProfileFromDesktopURL(); err != nil {
return fmt.Errorf("get enrollment profile from desktop URL: %w", err)
}
case c.fetchEnrollmentProfileFromDEPUsingPost:
if err := c.fetchEnrollmentProfileFromDEPURLUsingPost(); err != nil {
return fmt.Errorf("get enrollment profile using POST from DEP URL: %w", err)
}
case c.fetchEnrollmentProfileFromDEP:
if err := c.fetchEnrollmentProfileFromDEPURL(); err != nil {
return fmt.Errorf("get enrollment profile from DEP URL: %w", err)
@@ -235,7 +249,20 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
return fmt.Errorf("test client: encoding device info: %w", err)
}
return c.fetchEnrollmentProfile(
apple_mdm.EnrollPath + "?token=" + c.depURLToken + "&deviceinfo=" + di,
apple_mdm.EnrollPath+"?token="+c.depURLToken+"&deviceinfo="+di, nil,
)
}
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURLUsingPost() error {
buf, err := MachineInfoAsPKCS7(fleet.MDMAppleMachineInfo{
Serial: c.SerialNumber,
UDID: c.UUID,
})
if err != nil {
return fmt.Errorf("test client: encoding device info: %w", err)
}
return c.fetchEnrollmentProfile(
apple_mdm.EnrollPath+"?token="+c.depURLToken, buf,
)
}
@@ -400,10 +427,19 @@ func (c *TestAppleMDMClient) fetchOTAProfile(url string) error {
return nil
}
func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error {
request, err := http.NewRequest("GET", c.fleetServerURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string, body []byte) (err error) {
var request *http.Request
if len(body) > 0 {
request, err = http.NewRequest("POST", c.fleetServerURL+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
request.Header.Set("Content-Type", "application/pkcs7-signature")
} else {
request, err = http.NewRequest("GET", c.fleetServerURL+path, 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{
@@ -417,7 +453,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error {
if response.StatusCode != http.StatusOK {
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
body, err := io.ReadAll(response.Body)
rspBody, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
@@ -425,9 +461,9 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error {
return fmt.Errorf("close body: %w", err)
}
rawProfile := body
rawProfile := rspBody
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(body)
p7, err := pkcs7.Parse(rspBody)
if err != nil {
return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err)
}
@@ -1013,36 +1049,44 @@ func makeClientSCEPEndpoints(instance string) (*scepserver.Endpoints, error) {
// EncodeDeviceInfo is a helper function to provide mock device info for the x-aspen-deviceinfo
// header that is sent by the device during the Apple MDM enrollment process.
func EncodeDeviceInfo(machineInfo fleet.MDMAppleMachineInfo) (string, error) {
sig, err := MachineInfoAsPKCS7(machineInfo)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(sig), nil
}
// MachineInfoAsPKCS7 marshals and signs Apple's machine info.
func MachineInfoAsPKCS7(machineInfo fleet.MDMAppleMachineInfo) ([]byte, error) {
body, err := plist.Marshal(machineInfo)
if err != nil {
return "", fmt.Errorf("marshal device info: %w", err)
return nil, fmt.Errorf("marshal device info: %w", err)
}
// body is expected to be a PKCS7 signed message, although we don't currently verify the signature
signedData, err := pkcs7.NewSignedData(body)
if err != nil {
return "", fmt.Errorf("create signed data: %w", err)
return nil, fmt.Errorf("create signed data: %w", err)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", fmt.Errorf("generate RSA private key: %w", err)
return nil, fmt.Errorf("generate RSA private key: %w", err)
}
crtBytes, err := depot.NewCACert().SelfSign(rand.Reader, key.Public(), key)
if err != nil {
return "", fmt.Errorf("create self-signed certificate: %w", err)
return nil, fmt.Errorf("create self-signed certificate: %w", err)
}
crt, err := x509.ParseCertificate(crtBytes)
if err != nil {
return "", fmt.Errorf("parse self-signed certificate: %w", err)
return nil, fmt.Errorf("parse self-signed certificate: %w", err)
}
if err := signedData.AddSigner(crt, key, pkcs7.SignerInfoConfig{}); err != nil {
return "", fmt.Errorf("add signer: %w", err)
return nil, fmt.Errorf("add signer: %w", err)
}
sig, err := signedData.Finish()
if err != nil {
return "", fmt.Errorf("finish signing: %w", err)
return nil, fmt.Errorf("finish signing: %w", err)
}
return base64.URLEncoding.EncodeToString(sig), nil
return sig, nil
}
+1
View File
@@ -546,6 +546,7 @@ var hostRefs = []string{
"android_devices",
"host_scim_user",
"batch_script_execution_host_results",
"host_mdm_commands",
}
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
+6
View File
@@ -7099,6 +7099,12 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
VALUES (?, uuid(), uuid())
`, host.ID)
require.NoError(t, err)
// Update the host_mdm_commands table
_, err = ds.writer(context.Background()).Exec(`
INSERT INTO host_mdm_commands (host_id, command_type)
VALUES (?, 'REFETCH-DEVICE-')
`, host.ID)
require.NoError(t, err)
// Add a calendar event for the host.
_, err = ds.writer(context.Background()).Exec(`
+12 -10
View File
@@ -161,13 +161,7 @@ func (d *DEPService) buildJSONProfile(ctx context.Context, setupAsstJSON json.Ra
// IT admin.
if jsonProf.ConfigurationWebURL == "" {
// If SSO is configured, use the `/mdm/sso` page which starts the SSO
// flow, otherwise use Fleet's enroll URL.
//
// Even though the DEP profile supports an `url` attribute, we should
// always still set configuration_web_url, otherwise the request method
// coming from Apple changes from GET to POST, and we want to preserve
// backwards compatibility.
jsonProf.ConfigurationWebURL = enrollURL
// flow, otherwise leave it blank.
endUserAuthEnabled := appCfg.MDM.MacOSSetup.EnableEndUserAuthentication
if team != nil {
endUserAuthEnabled = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication
@@ -177,9 +171,17 @@ func (d *DEPService) buildJSONProfile(ctx context.Context, setupAsstJSON json.Ra
}
}
// ensure `url` is the same as `configuration_web_url`, to not leak the URL
// to get a token without SSO enabled
jsonProf.URL = jsonProf.ConfigurationWebURL
if jsonProf.ConfigurationWebURL != "" {
// ensure `url` is the same as `configuration_web_url`, to not leak the URL
// to get a token without SSO enabled
jsonProf.URL = jsonProf.ConfigurationWebURL
} else {
// Without configuration_web_url set, the host will send a POST request
// to `url` location to get the MDM profile.
// 2025-05-20 unofficial docs: https://github.com/4d-for-ios-sdk/Mobile-Device-Management-Protocol-Reference/blob/master/markdown/4-Profile_Management/4-Profile_Management.md#request-to-a-profile-url
jsonProf.URL = enrollURL
}
// always set await_device_configured to true - it will be released either
// automatically by Fleet or manually by the user if
// enable_release_device_manually is true.
+2 -1
View File
@@ -17,6 +17,7 @@ import (
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/go-kit/log"
"github.com/micromdm/plist"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -43,7 +44,7 @@ func TestDEPService(t *testing.T) {
err = json.Unmarshal(body, &got)
require.NoError(t, err)
require.Contains(t, got.URL, serverURL+"api/mdm/apple/enroll?token=")
require.Contains(t, got.ConfigurationWebURL, serverURL+"api/mdm/apple/enroll?token=")
assert.Empty(t, got.ConfigurationWebURL)
got.URL = ""
got.ConfigurationWebURL = ""
defaultProfile.AwaitDeviceConfigured = true // this is now always set to true
+4
View File
@@ -142,6 +142,10 @@ func ParseDeviceinfo(b64 string, verify bool) (*fleet.MDMAppleMachineInfo, error
}
}
return ParseMachineInfoFromPKCS7(buf, verify)
}
func ParseMachineInfoFromPKCS7(buf []byte, verify bool) (*fleet.MDMAppleMachineInfo, error) {
p7, err := pkcs7.Parse(buf)
if err != nil {
return nil, fmt.Errorf("could not decode pkcs7: %w", err)
+22
View File
@@ -63,6 +63,7 @@ import (
const (
maxValueCharsInError = 100
SameProfileNameUploadErrorMsg = "Couldn't add. A configuration profile with this name already exists (PayloadDisplayName for .mobileconfig and file name for .json and .xml)."
limit10KiB = 10 * 1024
)
var (
@@ -1756,6 +1757,27 @@ func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request)
decoded.MachineInfo = parsed
}
if decoded.MachineInfo == nil && r.Header.Get("Content-Type") == "application/pkcs7-signature" {
defer r.Body.Close()
// We limit the amount we read since this is an untrusted HTTP request -- a potential DoS attack from huge payloads.
body, err := io.ReadAll(io.LimitReader(r.Body, limit10KiB))
if err != nil {
return nil, &fleet.BadRequestError{
Message: "unable to read request body",
InternalErr: err,
}
}
// FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
decoded.MachineInfo, err = apple_mdm.ParseMachineInfoFromPKCS7(body, false)
if err != nil {
return nil, &fleet.BadRequestError{
Message: "unable to parse machine info",
InternalErr: err,
}
}
}
return &decoded, nil
}
+1
View File
@@ -898,6 +898,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// endpoints using `mdmAppleMW.*` above in this file.
neAppleMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())
neAppleMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{})
neAppleMDM.POST(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{})
neAppleMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{})
neAppleMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{})
neAppleMDM.POST("/api/_version_/fleet/ota_enrollment", mdmAppleOTAEndpoint, mdmAppleOTARequest{})
+36 -26
View File
@@ -129,10 +129,11 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;new_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
@@ -154,10 +155,11 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;bypass_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnableReleaseManually: enableReleaseManually,
TeamID: nil,
CustomProfileIdent: "I1",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
@@ -268,10 +270,11 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;new_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
@@ -293,10 +296,11 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t;bypass_flow", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
@@ -373,10 +377,11 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseIphoneTeam() {
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, DEPEnrollTestOpts{
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnableReleaseManually: enableReleaseManually,
TeamID: &tm.ID,
CustomProfileIdent: "I2",
UseOldFleetdFlow: false,
EnrollmentProfileFromDEPUsingPost: true,
})
})
}
@@ -384,11 +389,12 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseIphoneTeam() {
// DEPEnrollTestOpts contains options for DEP enrollment and release device tests
type DEPEnrollTestOpts struct {
EnableReleaseManually bool
TeamID *uint
CustomProfileIdent string
UseOldFleetdFlow bool
ManualAgentInstall bool
EnableReleaseManually bool
TeamID *uint
CustomProfileIdent string
UseOldFleetdFlow bool
ManualAgentInstall bool
EnrollmentProfileFromDEPUsingPost bool
}
func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, device godep.Device, opts DEPEnrollTestOpts) {
@@ -478,7 +484,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
// enroll the host
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
clientOpts := make([]mdmtest.TestMDMAppleClientOption, 0)
if opts.EnrollmentProfileFromDEPUsingPost {
clientOpts = append(clientOpts, mdmtest.WithEnrollmentProfileFromDEPUsingPost())
}
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken, clientOpts...)
if isIphone {
mdmDevice.Model = "iPhone 14,6"
}
+16 -5
View File
@@ -5271,7 +5271,8 @@ func (s *integrationMDMTestSuite) TestSSO() {
assert.Empty(t, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
s.runWorker()
require.Equal(t, lastSubmittedProfile.ConfigurationWebURL, lastSubmittedProfile.URL)
assert.Empty(t, lastSubmittedProfile.ConfigurationWebURL)
assert.NotEmpty(t, lastSubmittedProfile.URL)
// test basic authentication for each supported config flow.
//
@@ -9824,6 +9825,7 @@ func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
s.enableABM(t.Name())
var lastSubmittedProfile *godep.Profile
configurationWebURLShouldBeEmpty := true
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
@@ -9857,8 +9859,12 @@ func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
// check that the urls are not empty and equal
require.NotEmpty(t, lastSubmittedProfile.URL)
require.NotEmpty(t, lastSubmittedProfile.ConfigurationWebURL)
require.Equal(t, lastSubmittedProfile.URL, lastSubmittedProfile.ConfigurationWebURL)
if configurationWebURLShouldBeEmpty {
assert.Empty(t, lastSubmittedProfile.ConfigurationWebURL)
} else {
require.NotEmpty(t, lastSubmittedProfile.ConfigurationWebURL)
assert.Equal(t, lastSubmittedProfile.URL, lastSubmittedProfile.ConfigurationWebURL)
}
err = encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
require.NoError(t, err)
default:
@@ -9896,6 +9902,7 @@ func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
}`), http.StatusOK, &acResp)
// assign the DEP profile and assert that contains the right values for the URL
configurationWebURLShouldBeEmpty = false
s.runWorker()
require.NotNil(t, lastSubmittedProfile)
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/mdm/sso")
@@ -9926,12 +9933,14 @@ func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
}`), http.StatusOK, &acResp)
// assign the DEP profile and assert that contains the right values for the URL
configurationWebURLShouldBeEmpty = true
s.runWorker()
require.NotNil(t, lastSubmittedProfile)
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
// setting a custom configuration_web_url succeeds because user authentication is disabled
globalAsstResp = createMDMAppleSetupAssistantResponse{}
configurationWebURLShouldBeEmpty = false
s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team",
@@ -10046,11 +10055,13 @@ func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() {
require.Len(t, applyResp.TeamIDsByName, 1)
// assign the DEP profile and assert that contains the right values for the URL
configurationWebURLShouldBeEmpty = true
s.runWorker()
require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
// setting configuration_web_url succeeds because end user authentication is disabled
tmAsstResp = createMDMAppleSetupAssistantResponse{}
configurationWebURLShouldBeEmpty = false
s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: &teamID,
Name: t.Name(),