diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index ddc26fad36..bfb17112e5 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -1,7 +1,14 @@ package fleet import ( + "errors" + "fmt" + "strings" "time" + + "github.com/beevik/etree" + "github.com/fleetdm/fleet/v4/server/mdm" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" ) // MDMWindowsBitLockerSummary reports the number of Windows hosts being managed by Fleet with @@ -29,6 +36,57 @@ type MDMWindowsConfigProfile struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// ValidateUserProvided ensures that the SyncML content in the profile is valid +// for Windows. +// +// It checks that all top-level elements are and none of the +// elements within are reserved URIs. +// +// Returns an error if these conditions are not met. +func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { + if mdm.GetRawProfilePlatform(m.SyncML) != "windows" { + return errors.New("Only supported as a top level element. Make sure you don’t have other top level elements.") + } + + doc := etree.NewDocument() + if err := doc.ReadFromBytes(m.SyncML); err != nil { + return fmt.Errorf("Couldn’t upload. The file should include valid XML: %w", err) + } + + for _, element := range doc.ChildElements() { + if element.Tag != CmdReplace { + return errors.New("Only supported as a top level element. Make sure you don’t have other top level elements.") + } + + for _, target := range element.FindElements("Target") { + locURI := target.FindElement("LocURI") + if locURI != nil { + if err := validateFleetProvidedLocURI(locURI.Text()); err != nil { + return err + } + } + } + } + + return nil +} + +var fleetProvidedLocURIValidationMap = map[string][2]string{ + microsoft_mdm.FleetBitLockerTargetLocURI: {"BitLocker", "mdm.enable_disk_encryption"}, + microsoft_mdm.FleetOSUpdateTargetLocURI: {"Windows updates", "mdm.windows_updates"}, +} + +func validateFleetProvidedLocURI(locURI string) error { + sanitizedLocURI := strings.TrimSpace(locURI) + for fleetLocURI, errHints := range fleetProvidedLocURIValidationMap { + if strings.Contains(sanitizedLocURI, fleetLocURI) { + return fmt.Errorf("Custom configuration profiles can’t include %s settings. To control these settings, use the %s option.", errHints[0], errHints[1]) + } + } + + return nil +} + type MDMWindowsProfilePayload struct { ProfileUUID string `db:"profile_uuid"` ProfileName string `db:"profile_name"` diff --git a/server/fleet/windows_mdm_test.go b/server/fleet/windows_mdm_test.go new file mode 100644 index 0000000000..a74aeb86af --- /dev/null +++ b/server/fleet/windows_mdm_test.go @@ -0,0 +1,83 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateUserProvided(t *testing.T) { + tests := []struct { + name string + profile MDMWindowsConfigProfile + wantErr bool + }{ + { + name: "Valid XML with Replace", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URI`), + }, + wantErr: false, + }, + { + name: "Invalid Platform", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URI`), + }, + wantErr: true, + }, + { + name: "Invalid XML Structure", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URI`), + }, + wantErr: true, + }, + { + name: "Reserved LocURI", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`./Device/Vendor/MSFT/BitLocker/Foo`), + }, + wantErr: true, + }, + { + name: "XML with Multiple Replace Elements", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URI1Custom/URI2`), + }, + wantErr: false, + }, + { + name: "Empty XML", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(``), + }, + wantErr: true, + }, + { + name: "XML with Multiple Replace Elements, One with Reserved LocURI", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URI./Device/Vendor/MSFT/BitLocker/Bar`), + }, + wantErr: true, + }, + { + name: "XML with Mixed Replace and Add", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(`Custom/URIAnother/URI`), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.profile.ValidateUserProvided() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 79a55dd50a..16a52b00ee 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -1,6 +1,7 @@ package mdm import ( + "bytes" "crypto" "crypto/x509" "encoding/base64" @@ -23,3 +24,23 @@ func DecryptBase64CMS(p7Base64 string, cert *x509.Certificate, key crypto.Privat return p7.Decrypt(cert, key) } + +func GetRawProfilePlatform(profile []byte) string { + trimmedProfile := bytes.TrimSpace(profile) + + if len(trimmedProfile) == 0 { + return "" + } + + darwinPrefix := []byte("= len(darwinPrefix) && bytes.EqualFold(darwinPrefix, trimmedProfile[:len(darwinPrefix)]) { + return "darwin" + } + + windowsPrefix := []byte("= len(windowsPrefix) && bytes.EqualFold(windowsPrefix, trimmedProfile[:len(windowsPrefix)]) { + return "windows" + } + + return "" +} diff --git a/server/mdm/mdm_test.go b/server/mdm/mdm_test.go index 35f151ac1d..a85b9e1597 100644 --- a/server/mdm/mdm_test.go +++ b/server/mdm/mdm_test.go @@ -117,3 +117,64 @@ oHwpyQbv9Qs+3bjPOQ7DkwekT+w1cptEKudBCC3WQKui1P0NNL0R // prevent static analysis tools from raising issues due to detection of private key // in code. func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +func TestGetRawProfilePlatform(t *testing.T) { + testCases := []struct { + name string + input []byte + expected string + }{ + { + name: "Darwin case sensitive", + input: []byte(""), + expected: "darwin", + }, + { + name: "Darwin case insensitive", + input: []byte(""), + expected: "darwin", + }, + { + name: "Windows case sensitive", + input: []byte(""), + expected: "windows", + }, + { + name: "Windows case insensitive", + input: []byte(""), + expected: "windows", + }, + { + name: "Whitespace before prefix", + input: []byte(" "), + expected: "darwin", + }, + { + name: "Non-matching prefix", + input: []byte(""), + expected: "", + }, + { + name: "Empty input", + input: []byte(""), + expected: "", + }, + { + name: "Only whitespaces", + input: []byte(" "), + expected: "", + }, + { + name: "Partial match", + input: []byte("