Add logic for Windows profiles validation (#15120)
related to #14361 and #14366 this adds validations to user-provided Windows profiles
This commit is contained in:
@@ -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 <Replace> and none of the <LocURI>
|
||||
// elements within <Target> 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 <Replace> 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 <Replace> 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"`
|
||||
|
||||
@@ -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(`<Replace><Target><LocURI>Custom/URI</LocURI></Target></Replace>`),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid Platform",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`<SyncML xmlns="SYNCML:SYNCML1.2"><Replace><Target><LocURI>Custom/URI</LocURI></Target></Replace></SyncML>`),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid XML Structure",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`<Add><Target><LocURI>Custom/URI</LocURI></Target></Add>`),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Reserved LocURI",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`<Replace><Target><LocURI>./Device/Vendor/MSFT/BitLocker/Foo</LocURI></Target></Replace>`),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "XML with Multiple Replace Elements",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`<Replace><Target><LocURI>Custom/URI1</LocURI></Target></Replace><Replace><Target><LocURI>Custom/URI2</LocURI></Target></Replace>`),
|
||||
},
|
||||
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(`<Replace><Target><LocURI>Custom/URI</LocURI></Target></Replace><Replace><Target><LocURI>./Device/Vendor/MSFT/BitLocker/Bar</LocURI></Target></Replace>`),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "XML with Mixed Replace and Add",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`<Replace><Target><LocURI>Custom/URI</LocURI></Target></Replace><Add><Target><LocURI>Another/URI</LocURI></Target></Add>`),
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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("<?xml")
|
||||
if len(trimmedProfile) >= len(darwinPrefix) && bytes.EqualFold(darwinPrefix, trimmedProfile[:len(darwinPrefix)]) {
|
||||
return "darwin"
|
||||
}
|
||||
|
||||
windowsPrefix := []byte("<replace")
|
||||
if len(trimmedProfile) >= len(windowsPrefix) && bytes.EqualFold(windowsPrefix, trimmedProfile[:len(windowsPrefix)]) {
|
||||
return "windows"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
|
||||
expected: "darwin",
|
||||
},
|
||||
{
|
||||
name: "Darwin case insensitive",
|
||||
input: []byte("<?XML version=\"1.0\" encoding=\"UTF-8\"?>"),
|
||||
expected: "darwin",
|
||||
},
|
||||
{
|
||||
name: "Windows case sensitive",
|
||||
input: []byte("<Replace this=\"that\">"),
|
||||
expected: "windows",
|
||||
},
|
||||
{
|
||||
name: "Windows case insensitive",
|
||||
input: []byte("<REPLACE this=\"that\">"),
|
||||
expected: "windows",
|
||||
},
|
||||
{
|
||||
name: "Whitespace before prefix",
|
||||
input: []byte(" <?xml version=\"1.0\"?>"),
|
||||
expected: "darwin",
|
||||
},
|
||||
{
|
||||
name: "Non-matching prefix",
|
||||
input: []byte("<nonmatching>"),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Empty input",
|
||||
input: []byte(""),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Only whitespaces",
|
||||
input: []byte(" "),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Partial match",
|
||||
input: []byte("<?x"),
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GetRawProfilePlatform(tt.input)
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,11 @@ const (
|
||||
CmdAlertGeneric = "1226"
|
||||
)
|
||||
|
||||
const (
|
||||
FleetBitLockerTargetLocURI = "./Device/Vendor/MSFT/BitLocker"
|
||||
FleetOSUpdateTargetLocURI = "./Device/Vendor/MSFT/Policy/Config/Update/"
|
||||
)
|
||||
|
||||
func ResolveWindowsMDMDiscovery(serverURL string) (string, error) {
|
||||
return commonmdm.ResolveURL(serverURL, MDE2DiscoveryPath, false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user