fleet/server/mdm/apple/mobileconfig/mobileconfig.go
Jordan Montgomery 7593d102fb
Experimental fleet server config for custom updates & disk encryption settings (#34598)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #33316

Merges in changes made in this community PR:
https://github.com/fleetdm/fleet/pull/33665

Adds support for Windows and tests, also blocks the feature on fleet
free

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

---------

Co-authored-by: Wesley Whetstone <wesw@stripe.com>
Co-authored-by: Wesley Whetstone <jckwhet@gmail.com>
2025-10-22 13:51:10 -04:00

299 lines
9.8 KiB
Go

package mobileconfig
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/variables"
"howett.net/plist"
)
const (
// FleetFileVaultPayloadIdentifier is the value for the PayloadIdentifier
// used by Fleet to configure FileVault and FileVault Escrow.
FleetFileVaultPayloadIdentifier = "com.fleetdm.fleet.mdm.filevault"
FleetFileVaultPayloadType = "com.apple.MCX.FileVault2"
FleetCustomSettingsPayloadType = "com.apple.MCX"
FleetRecoveryKeyEscrowPayloadType = "com.apple.security.FDERecoveryKeyEscrow"
DiskEncryptionProfileRestrictionErrMsg = "Couldn't add. The configuration profile can't include FileVault settings."
// FleetdConfigPayloadIdentifier is the value for the PayloadIdentifier used
// by fleetd to read configuration values from the system.
FleetdConfigPayloadIdentifier = "com.fleetdm.fleetd.config"
// FleetCARootConfigPayloadIdentifier TODO
FleetCARootConfigPayloadIdentifier = "com.fleetdm.caroot"
// FleetEnrollmentPayloadIdentifier is the value for the PayloadIdentifier used
// by Fleet to enroll a device with the MDM server.
FleetEnrollmentPayloadIdentifier = "com.fleetdm.fleet.mdm.apple.mdm"
// FleetEnrollReferenceKey is the key used by Fleet of the URL query parameter representing a unique
// identifier for an MDM enrollment. The unique value of the query parameter is appended to the
// Fleet server URL when an MDM enrollment profile is generated for download by a device.
//
// TODO: We have some inconsistencies where we use enroll_reference sometimes and
// enrollment_reference other times. It really should be the same everywhere, but
// it seems to be working now because the values are matching where they need to match.
// We should clean this up at some point and update hardcoded values in the codebase.
FleetEnrollReferenceKey = "enroll_reference"
)
// FleetPayloadIdentifiers returns a map of PayloadIdentifier strings
// that are handled and delivered by Fleet.
//
// TODO(roperzh): at some point we should also include
// apple_mdm.FletPayloadIdentifier here too, but that requires moving a lot of
// files around due to import cycles.
func FleetPayloadIdentifiers() map[string]struct{} {
return map[string]struct{}{
FleetFileVaultPayloadIdentifier: {},
FleetdConfigPayloadIdentifier: {},
FleetCARootConfigPayloadIdentifier: {},
}
}
// FleetPayloadTypes returns a map of PayloadType strings
// that are fully or partially handled and delivered by Fleet.
func FleetPayloadTypes() map[string]struct{} {
return map[string]struct{}{
FleetRecoveryKeyEscrowPayloadType: {},
FleetFileVaultPayloadType: {},
FleetCustomSettingsPayloadType: {},
"com.apple.security.FDERecoveryRedirect": {}, // no longer supported in macOS 10.13 and later
}
}
// Mobileconfig is the byte slice corresponding to an XML property list (i.e. plist) representation
// of an Apple MDM configuration profile in Fleet.
//
// Configuration profiles are used to configure Apple devices. See also
// https://developer.apple.com/documentation/devicemanagement/configuring_multiple_devices_using_profiles.
type Mobileconfig []byte
type Parsed struct {
PayloadIdentifier string
PayloadDisplayName string
PayloadType string
PayloadScope string
}
func (mc Mobileconfig) IsSignedProfile() bool {
trimmed := bytes.TrimSpace(mc)
if bytes.HasPrefix(trimmed, []byte("${FLEET_")) || bytes.HasPrefix(trimmed, []byte("$FLEET_")) {
return false // Not a signed profile since it only contains secret variable.
}
return !bytes.HasPrefix(bytes.TrimSpace(mc), []byte("<?xml"))
}
// ParseConfigProfile attempts to parse the Mobileconfig byte slice as a Fleet MDMAppleConfigProfile.
//
// The byte slice must be XML or PKCS7 parseable. Fleet also requires that it contains both
// a PayloadIdentifier and a PayloadDisplayName and that it has PayloadType set to "Configuration".
//
// Adapted from https://github.com/micromdm/micromdm/blob/main/platform/profile/profile.go
func (mc Mobileconfig) ParseConfigProfile() (*Parsed, error) {
mcBytes := mc
// Remove Fleet variables expected in <data> section.
mcBytes = variables.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
if mc.IsSignedProfile() {
return nil, errors.New("signed profiles are not supported")
}
var p Parsed
if _, err := plist.Unmarshal(mcBytes, &p); err != nil {
return nil, err
}
if p.PayloadType != "Configuration" {
return nil, fmt.Errorf("invalid PayloadType: %s", p.PayloadType)
}
if p.PayloadIdentifier == "" {
return nil, errors.New("empty PayloadIdentifier in profile")
}
if p.PayloadDisplayName == "" {
return nil, errors.New("empty PayloadDisplayName in profile")
}
// PayloadScope is optional and according to
// Apple(https://developer.apple.com/business/documentation/Configuration-Profile-Reference.pdf
// p6) defaults to "User". We've always sent them to the Device channel but now we're saying
// "User" means use the user channel. For backwards compatibility we are maintaining existing
// behavior of defaulting to device channel below but we should consider whether this is correct.
if p.PayloadScope == "" {
p.PayloadScope = "System"
}
if p.PayloadScope != "System" && p.PayloadScope != "User" {
return nil, fmt.Errorf("invalid PayloadScope: %s", p.PayloadScope)
}
return &p, nil
}
type payloadSummary struct {
Type string
Identifier string
Name string
}
// payloadSummary attempts to parse the PayloadContent list of the Mobileconfig's TopLevel object.
// It returns the PayloadType for each PayloadContentItem.
//
// See also https://developer.apple.com/documentation/devicemanagement/toplevel
func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) {
mcBytes := mc
// Remove Fleet variables expected in <data> section.
mcBytes = variables.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
if mc.IsSignedProfile() {
return nil, errors.New("signed profiles are not supported")
}
// unmarshal the values we need from the top-level object
var tlo struct {
IsEncrypted bool
PayloadContent []map[string]interface{}
PayloadType string
}
_, err := plist.Unmarshal(mcBytes, &tlo)
if err != nil {
return nil, err
}
// confirm that the top-level payload type matches the expected value
if tlo.PayloadType != "Configuration" {
return nil, &ErrInvalidPayloadType{tlo.PayloadType}
}
if len(tlo.PayloadContent) < 1 {
if tlo.IsEncrypted {
return nil, ErrEncryptedPayloadContent
}
return nil, ErrEmptyPayloadContent
}
// extract the payload types of each payload content item from the array of
// payload dictionaries
var result []payloadSummary
for _, payloadDict := range tlo.PayloadContent {
summary := payloadSummary{}
pt, ok := payloadDict["PayloadType"]
if ok {
if s, ok := pt.(string); ok {
summary.Type = s
}
}
pi, ok := payloadDict["PayloadIdentifier"]
if ok {
if s, ok := pi.(string); ok {
summary.Identifier = s
}
}
pdn, ok := payloadDict["PayloadDisplayName"]
if ok {
if s, ok := pdn.(string); ok {
summary.Name = s
}
}
if summary.Type != "" || summary.Identifier != "" || summary.Name != "" {
result = append(result, summary)
}
}
return result, nil
}
func (mc *Mobileconfig) ScreenPayloads(allowCustomOSUpdatesAndFileVault bool) error {
pct, err := mc.payloadSummary()
if err != nil {
// don't error if there's nothing for us to screen.
if !errors.Is(err, ErrEmptyPayloadContent) && !errors.Is(err, ErrEncryptedPayloadContent) {
return err
}
}
fleetNames := mdm.FleetReservedProfileNames()
fleetIdentifiers := FleetPayloadIdentifiers()
fleetTypes := FleetPayloadTypes()
screenedTypes := []string{}
screenedIdentifiers := []string{}
screenedNames := []string{}
for _, t := range pct {
if _, ok := fleetTypes[t.Type]; ok {
screenedTypes = append(screenedTypes, t.Type)
}
if _, ok := fleetIdentifiers[t.Identifier]; ok {
screenedIdentifiers = append(screenedIdentifiers, t.Identifier)
}
if _, ok := fleetNames[t.Name]; ok {
screenedNames = append(screenedNames, t.Name)
}
}
if len(screenedTypes) > 0 {
var unsupportedTypes []string
for _, t := range screenedTypes {
switch t {
case FleetFileVaultPayloadType, FleetRecoveryKeyEscrowPayloadType:
if !allowCustomOSUpdatesAndFileVault {
return errors.New(DiskEncryptionProfileRestrictionErrMsg)
}
case FleetCustomSettingsPayloadType:
contains, err := ContainsFDEFileVaultOptionsPayload(*mc)
if err != nil {
return fmt.Errorf("checking for FDEVileVaultOptions payload: %w", err)
}
if contains && !allowCustomOSUpdatesAndFileVault {
return errors.New(DiskEncryptionProfileRestrictionErrMsg)
}
default:
unsupportedTypes = append(unsupportedTypes, t)
}
}
if len(unsupportedTypes) > 0 {
return fmt.Errorf("unsupported PayloadType(s): %s", strings.Join(screenedTypes, ", "))
}
}
if len(screenedIdentifiers) > 0 {
return fmt.Errorf("unsupported PayloadIdentifier(s): %s", strings.Join(screenedIdentifiers, ", "))
}
if len(screenedNames) > 0 {
return fmt.Errorf("unsupported PayloadDisplayName(s): %s", strings.Join(screenedNames, ", "))
}
return nil
}
type ErrInvalidPayloadType struct {
payloadType string
}
func (e ErrInvalidPayloadType) Error() string {
return fmt.Sprintf("invalid PayloadType: %s", e.payloadType)
}
var (
ErrEmptyPayloadContent = errors.New("empty PayloadContent")
ErrEncryptedPayloadContent = errors.New("encrypted PayloadContent")
)
// XMLEscapeString returns the escaped XML equivalent of the plain text data s.
func XMLEscapeString(s string) (string, error) {
// avoid allocation if we can.
if !strings.ContainsAny(s, "'\"&<>\t\n\r") {
return s, nil
}
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return "", err
}
return b.String(), nil
}