diff --git a/changes/48752-windows-reserved-locuri-scopeless-bypass b/changes/48752-windows-reserved-locuri-scopeless-bypass new file mode 100644 index 0000000000..c3fb825680 --- /dev/null +++ b/changes/48752-windows-reserved-locuri-scopeless-bypass @@ -0,0 +1 @@ +- Fixed a bug where a custom Windows configuration profile/command could bypass Fleet's checks by using a scope-less LocURI. diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 5b08cca5a7..5def5535ee 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -1328,7 +1328,7 @@ func validateOSUpdatesProfileConflict(controls GitOpsControls) error { if windowsConfigured { windowsSettings, _ := controls.WindowsSettings.(fleet.WindowsSettings) for _, profile := range windowsSettings.CustomSettings.Value { - contains, err := profileFileContains(profile.Path, syncml.FleetOSUpdateTargetLocURI) + contains, err := windowsProfileFileTargetsReservedLocURI(profile.Path, syncml.FleetOSUpdateTargetLocURI) if err != nil { return err } @@ -1376,6 +1376,19 @@ func profileFileContains(path, needle string) (bool, error) { return bytes.Contains(fileBytes, []byte(needle)), nil } +// windowsProfileFileTargetsReservedLocURI reports whether the Windows profile file at path targets the given Fleet-reserved +// LocURI node. +func windowsProfileFileTargetsReservedLocURI(path, reservedLocURI string) (bool, error) { + if path == "" { + return false, nil + } + fileBytes, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("failed to read profile file %s: %v", path, err) + } + return fleet.ProfileTargetsReservedLocURI(fileBytes, reservedLocURI), nil +} + func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, controlsFilePath *string) []error { if controlsTop.Path == nil { result.Controls = controlsTop diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 82988c3a0e..7e698025cc 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1,7 +1,6 @@ package mysql import ( - "bytes" "context" "database/sql" "errors" @@ -635,7 +634,7 @@ func batchTrackUpdateConfigProfilesDB(ctx context.Context, tx sqlx.ExtContext, t } for _, p := range winProfiles { - if !bytes.Contains(p.SyncML, []byte(syncml.FleetOSUpdateTargetLocURI)) { + if !fleet.ProfileTargetsReservedLocURI(p.SyncML, syncml.FleetOSUpdateTargetLocURI) { continue } var profileUUID string diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 599990e454..6c089d6586 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -1086,9 +1086,8 @@ func (ds *Datastore) MDMWindowsSaveResponse(ctx context.Context, enrolledDevice args = append(args, enrolledDevice.ID, cmd.CommandUUID, rawResult, responseID, statusCode) sb.WriteString("(?, ?, ?, ?, ?),") - // if the command is a Wipe, keep track of it so we can update - // host_mdm_actions accordingly. - if strings.Contains(cmd.TargetLocURI, "/Device/Vendor/MSFT/RemoteWipe/") { + // if the command is a Wipe, keep track of it so we can update host_mdm_actions accordingly. + if fleet.LocURITargetsReservedNode(cmd.TargetLocURI, syncml.FleetRemoteWipeTargetLocURI) { wipeCmdUUID = cmd.CommandUUID wipeCmdStatus = statusCode } @@ -2402,7 +2401,7 @@ INSERT INTO // An OS-update profile is tracked as the team's OS-update profile within // this transaction so it rolls back together on failure. - if bytes.Contains(cp.SyncML, []byte(syncml.FleetOSUpdateTargetLocURI)) { + if fleet.ProfileTargetsReservedLocURI(cp.SyncML, syncml.FleetOSUpdateTargetLocURI) { if err := trackWindowsUpdateConfigProfileDB(ctx, tx, teamID, profileUUID); err != nil { return err } diff --git a/server/fleet/microsoft_mdm.go b/server/fleet/microsoft_mdm.go index 2175169579..fc8eaddf6f 100644 --- a/server/fleet/microsoft_mdm.go +++ b/server/fleet/microsoft_mdm.go @@ -17,7 +17,8 @@ import ( ) const ( - WINDOWS_SCEP_LOC_URI_PART = "/Vendor/MSFT/ClientCertificateInstall/SCEP" + // scepInstallLocURINode is the Windows SCEP ClientCertificateInstall node in scope-less form. + scepInstallLocURINode = "Vendor/MSFT/ClientCertificateInstall/SCEP" WindowsMDMAuthNoncePrefix = "mwenonce:" ) @@ -1161,7 +1162,9 @@ const WindowsMDMRequiresPremiumCmdMessage = "Missing or invalid license. Wipe co func (cmd SyncMLCmd) IsPremium() bool { // NOTE: if this implementation changes, make sure to also update the error // message above - the WindowsMDMRequiresPremiumCmdMessage constant. - return strings.Contains(cmd.GetTargetURI(), "/Device/Vendor/MSFT/RemoteWipe/") + // + // LocURITargetsReservedNode canonicalizes the target so the premium gate matches every LocURI form Windows accepts. + return LocURITargetsReservedNode(cmd.GetTargetURI(), syncml.FleetRemoteWipeTargetLocURI) } // DataType returns the SyncMLDataType corresponding to the command's format. @@ -1829,9 +1832,7 @@ func BuildDeleteCommandFromProfileBytes(profileBytes []byte, commandUUID string, normalized := FleetVarSCEPWindowsCertificateIDRegexp.ReplaceAll(profileBytes, []byte(profileUUID)) // Mirror the install-side behavior: SCEP profiles are wrapped in if not already. - if strings.Contains(string(normalized), WINDOWS_SCEP_LOC_URI_PART) && !strings.Contains(string(normalized), "") { - normalized = fmt.Appendf([]byte{}, "%s", normalized) - } + normalized = WrapSCEPProfileInAtomic(normalized) allURIs := ExtractLocURIsFromProfileBytes(normalized) if len(allURIs) == 0 { @@ -1922,16 +1923,22 @@ func CanonicalLocURI(locURI string) string { return s } +// WrapSCEPProfileInAtomic wraps profileBytes in when the profile targets the Windows SCEP ClientCertificateInstall +// node and isn't already wrapped. +func WrapSCEPProfileInAtomic(profileBytes []byte) []byte { + if bytes.Contains(profileBytes, []byte(scepInstallLocURINode)) && !bytes.Contains(profileBytes, []byte("")) { + return fmt.Appendf([]byte{}, "%s", profileBytes) + } + return profileBytes +} + // ExtractLocURIsFromProfileBytes returns all Target LocURIs found in the // profile's Replace and Add commands. Exec commands are excluded (they // trigger one-time actions, not persistent settings). For Atomic profiles, // nested commands are inspected. func ExtractLocURIsFromProfileBytes(profileBytes []byte) []string { // Mirror the install-side SCEP normalization. - normalized := profileBytes - if strings.Contains(string(normalized), WINDOWS_SCEP_LOC_URI_PART) && !strings.Contains(string(normalized), "") { - normalized = fmt.Appendf([]byte{}, "%s", normalized) - } + normalized := WrapSCEPProfileInAtomic(profileBytes) cmds, err := UnmarshallMultiTopLevelXMLProfile(normalized) if err != nil || len(cmds) == 0 { @@ -1962,3 +1969,28 @@ func ExtractLocURIsFromProfileBytes(profileBytes []byte) []string { } return uris } + +// LocURITargetsReservedNode reports whether locURI targets the given Fleet-reserved node, or any descendant of it, matching +// at path-segment boundaries. +func LocURITargetsReservedNode(locURI, reservedLocURI string) bool { + node := strings.Trim(reservedLocURI, "/") + // Frame both the canonicalized LocURI and the node with "/" so the substring match is boundary-safe on both ends. + return strings.Contains("/"+CanonicalLocURI(locURI)+"/", "/"+node+"/") +} + +// ProfileTargetsReservedLocURI reports whether any Target LocURI in the profile targets the given Fleet-reserved node. +func ProfileTargetsReservedLocURI(profileBytes []byte, reservedLocURI string) bool { + // Quick reject without parsing: the reserved node name (minus the "/" anchors) must appear literally somewhere for any + // LocURI form, scoped or scope-less, to target it. Most Windows profiles don't reference it, so this avoids the XML parse + // below for the common case (this helper runs in loops over every profile during profile set/update flows). + if !bytes.Contains(profileBytes, []byte(strings.Trim(reservedLocURI, "/"))) { + return false + } + // Confirm a real Add/Replace Target LocURI targets the node + for _, uri := range ExtractLocURIsFromProfileBytes(profileBytes) { + if LocURITargetsReservedNode(uri, reservedLocURI) { + return true + } + } + return false +} diff --git a/server/fleet/microsoft_mdm_test.go b/server/fleet/microsoft_mdm_test.go index ab74c1aeec..b9ac5b8ce0 100644 --- a/server/fleet/microsoft_mdm_test.go +++ b/server/fleet/microsoft_mdm_test.go @@ -842,6 +842,113 @@ func TestCanonicalLocURI(t *testing.T) { require.Equal(t, "DeviceLock/X", CanonicalLocURI("./DeviceLock/X")) } +func TestLocURITargetsReservedNode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + locURI string + reserved string + want bool + }{ + {name: "explicit device scope", locURI: "./Device/Vendor/MSFT/BitLocker/RequireDeviceEncryption", reserved: syncml.FleetBitLockerTargetLocURI, want: true}, + {name: "scope-less (regression #48752)", locURI: "Vendor/MSFT/BitLocker/RequireDeviceEncryption", reserved: syncml.FleetBitLockerTargetLocURI, want: true}, + {name: "user scope matches via Contains, not a prefix check", locURI: "./User/Vendor/MSFT/BitLocker/Foo", reserved: syncml.FleetBitLockerTargetLocURI, want: true}, + {name: "surrounding whitespace", locURI: " Vendor/MSFT/BitLocker/Foo ", reserved: syncml.FleetBitLockerTargetLocURI, want: true}, + // Boundary safety: a longer sibling segment that merely shares the reserved-node prefix must not match, on either end. + {name: "left boundary: node ending in Vendor is not reserved", locURI: "Custom/SomeVendor/MSFT/BitLocker/Foo", reserved: syncml.FleetBitLockerTargetLocURI, want: false}, + {name: "right boundary: BitLockerCustom sibling is not reserved", locURI: "Vendor/MSFT/BitLockerCustom/Foo", reserved: syncml.FleetBitLockerTargetLocURI, want: false}, + {name: "unrelated node", locURI: "./Device/Vendor/MSFT/DMClient/Foo", reserved: syncml.FleetBitLockerTargetLocURI, want: false}, + // The reserved node itself (no descendant leaf) matches (node-inclusive). + {name: "bare BitLocker node matches", locURI: "./Device/Vendor/MSFT/BitLocker", reserved: syncml.FleetBitLockerTargetLocURI, want: true}, + // One positive smoke per reserved constant so a future typo/rename is caught. + {name: "OS update", locURI: "Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate", reserved: syncml.FleetOSUpdateTargetLocURI, want: true}, + {name: "RemoteWipe operation", locURI: "./Device/Vendor/MSFT/RemoteWipe/doWipe", reserved: syncml.FleetRemoteWipeTargetLocURI, want: true}, + // RemoteWipe is a wipe-only subtree: the bare node matches (node-inclusive), a sibling does not (boundary). + {name: "bare RemoteWipe node matches (wipe-only subtree)", locURI: "Vendor/MSFT/RemoteWipe", reserved: syncml.FleetRemoteWipeTargetLocURI, want: true}, + {name: "RemoteWipeCustom sibling is not reserved", locURI: "Vendor/MSFT/RemoteWipeCustom/doWipe", reserved: syncml.FleetRemoteWipeTargetLocURI, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, LocURITargetsReservedNode(tt.locURI, tt.reserved)) + }) + } +} + +func TestSyncMLCmdIsPremium(t *testing.T) { + t.Parallel() + + newExecCmd := func(locURI string) SyncMLCmd { + return SyncMLCmd{ + XMLName: xml.Name{Local: "Exec"}, + Items: []CmdItem{{Target: new(locURI)}}, + } + } + tests := []struct { + name string + locURI string + want bool + }{ + {name: "explicit device wipe", locURI: "./Device/Vendor/MSFT/RemoteWipe/doWipe", want: true}, + {name: "scope-less wipe (regression #48752)", locURI: "Vendor/MSFT/RemoteWipe/doWipe", want: true}, + {name: "non-wipe command", locURI: "./DevDetail/SwV", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, newExecCmd(tt.locURI).IsPremium()) + }) + } +} + +func TestProfileTargetsReservedLocURI(t *testing.T) { + t.Parallel() + + osUpdate := syncml.FleetOSUpdateTargetLocURI + tests := []struct { + name string + syncML string + want bool + }{ + { + name: "scoped OS update profile (fast path)", + syncML: `./Device/Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate`, + want: true, + }, + { + name: "scope-less OS update profile (regression #48752)", + syncML: `Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate`, + want: true, + }, + { + name: "scope-less OS update inside Atomic", + syncML: `Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate`, + want: true, + }, + { + name: "non-OS-update profile", + syncML: `Vendor/MSFT/BitLocker/RequireDeviceEncryption`, + want: false, + }, + { + name: "node ending in Update is not reserved", + syncML: `Custom/Config/UpdatePolicy/AllowAutoUpdate`, + want: false, + }, + { + // Sibling segment sharing the reserved prefix must not be flagged (mentions the node name so the quick-reject + // filter passes, forcing the boundary-aware per-LocURI check to make the call). + name: "UpdateExtra sibling segment is not reserved", + syncML: `Vendor/MSFT/Policy/Config/UpdateExtra/AllowAutoUpdate`, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ProfileTargetsReservedLocURI([]byte(tt.syncML), osUpdate)) + }) + } +} + func TestIsFleetInternalCmdID(t *testing.T) { for _, tc := range []struct { name string diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 818be65646..aa4dba9687 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -304,9 +304,8 @@ var fleetProvidedLocURIValidationMap = map[string][]string{ } func validateFleetProvidedLocURI(locURI string, allowCustomDiskEncryption bool) error { - sanitizedLocURI := strings.TrimSpace(locURI) for fleetLocURI, errHints := range fleetProvidedLocURIValidationMap { - if strings.Contains(sanitizedLocURI, fleetLocURI) { + if LocURITargetsReservedNode(locURI, fleetLocURI) { if fleetLocURI == syncml.FleetBitLockerTargetLocURI { if allowCustomDiskEncryption { continue @@ -406,9 +405,9 @@ func newWindowsSCEPProfileValidator() *windowsSCEPProfileValidator { } func (v windowsSCEPProfileValidator) normalizeSCEPLocURI(locURI string) string { - trimmed := strings.TrimSpace(locURI) + normalized := canonicalizeSCEPScope(locURI) // Accept braces version of the Fleet Var, and normalize it to the non-braces for validation. - return strings.ReplaceAll(trimmed, FleetVarSCEPWindowsCertificateID.WithBraces(), FleetVarSCEPWindowsCertificateID.WithPrefix()) + return strings.ReplaceAll(normalized, FleetVarSCEPWindowsCertificateID.WithBraces(), FleetVarSCEPWindowsCertificateID.WithPrefix()) } func (v *windowsSCEPProfileValidator) isSCEPProfile() bool { @@ -508,6 +507,22 @@ func IsWindowsSCEPLocURI(locURI string) bool { strings.HasPrefix(locURI, "./User/Vendor/MSFT/ClientCertificateInstall/SCEP/") } +// canonicalizeSCEPScope rewrites a SCEP ClientCertificateInstall LocURI to its explicit scoped form so the SCEP validations +// (which key off the "./Device/"/"./User/" prefix) can't be bypassed by a scope-less spelling. Non-SCEP LocURIs are returned unchanged. +func canonicalizeSCEPScope(locURI string) string { + // CanonicalLocURI strips the device scope to the bare "Vendor/MSFT/..." form and preserves explicit user scope as + // "User/Vendor/MSFT/...". + canon := CanonicalLocURI(locURI) + switch { + case strings.HasPrefix(canon, scepInstallLocURINode+"/"): + return "./Device/" + canon + case strings.HasPrefix(canon, "User/"+scepInstallLocURINode+"/"): + return "./" + canon + default: + return locURI + } +} + func (v *windowsSCEPProfileValidator) finalizeValidation() error { if !v.isSCEPProfile() { // Cheeky validation here, to only allow Exec elements in SCEP profiles. diff --git a/server/fleet/windows_mdm_test.go b/server/fleet/windows_mdm_test.go index 4a64c3bd18..51f64ca904 100644 --- a/server/fleet/windows_mdm_test.go +++ b/server/fleet/windows_mdm_test.go @@ -79,6 +79,19 @@ func TestValidateUserProvided(t *testing.T) { ./Vendor/MSFT/BitLocker/Foo +`), + }, + wantErr: syncml.DiskEncryptionProfileRestrictionErrMsg, + }, + { + name: "Reserved LocURI with scope-less prefix", + profile: MDMWindowsConfigProfile{ + SyncML: []byte(` + + + Vendor/MSFT/BitLocker/RequireDeviceEncryption + + `), }, wantErr: syncml.DiskEncryptionProfileRestrictionErrMsg, @@ -702,6 +715,21 @@ func TestValidateUserProvided(t *testing.T) { }, wantErr: fmt.Sprintf("You must use \"$FLEET_VAR_%s\" after \"ClientCertificateInstall/SCEP/\".", FleetVarSCEPWindowsCertificateID), }, + { + name: fmt.Sprintf("scope-less SCEP LocURI is treated as Device SCEP (missing $FLEET_VAR_%s rejected)", FleetVarSCEPWindowsCertificateID), + profile: MDMWindowsConfigProfile{ + SyncML: []byte(` + + + + Vendor/MSFT/ClientCertificateInstall/SCEP/bogus-id-that-is-not-fleet-var/Install/CAThumbprint + + + + `), + }, + wantErr: fmt.Sprintf("You must use \"$FLEET_VAR_%s\" after \"ClientCertificateInstall/SCEP/\".", FleetVarSCEPWindowsCertificateID), + }, { name: "SCEP Profile with missing required LocURI", profile: MDMWindowsConfigProfile{ diff --git a/server/mdm/microsoft/syncml/syncml.go b/server/mdm/microsoft/syncml/syncml.go index 6a1f39d1b4..1bcc3d7a5c 100644 --- a/server/mdm/microsoft/syncml/syncml.go +++ b/server/mdm/microsoft/syncml/syncml.go @@ -172,8 +172,9 @@ const ( ) const ( - FleetBitLockerTargetLocURI = "/Vendor/MSFT/BitLocker" - FleetOSUpdateTargetLocURI = "/Vendor/MSFT/Policy/Config/Update" + FleetBitLockerTargetLocURI = "/Vendor/MSFT/BitLocker" + FleetOSUpdateTargetLocURI = "/Vendor/MSFT/Policy/Config/Update" + FleetRemoteWipeTargetLocURI = "/Vendor/MSFT/RemoteWipe" DiskEncryptionProfileRestrictionErrMsg = "Couldn't add. The configuration profile can't include BitLocker settings." ) diff --git a/server/service/mdm.go b/server/service/mdm.go index 85cf4b5e70..985288f385 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2312,7 +2312,7 @@ func (svc *Service) BatchSetMDMProfiles( } for _, p := range windowsProfilesSlice { - if !bytes.Contains(p.SyncML, []byte(syncml.FleetOSUpdateTargetLocURI)) { + if !fleet.ProfileTargetsReservedLocURI(p.SyncML, syncml.FleetOSUpdateTargetLocURI) { continue } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index bd942354d0..eeabd93386 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1187,6 +1187,16 @@ func TestEnqueueWindowsMDMCommand(t *testing.T) { `, "", "./Device/Vendor/MSFT/RemoteWipe/doWipe"}, + // Regression for #48752: a scope-less wipe LocURI (which Windows still executes) must not bypass the premium gate. + {"scope-less wipe, non premium license", false, ` + + 1 + + + Vendor/MSFT/RemoteWipe/doWipe + + + `, "Requires Fleet Premium license", ""}, {"non-premium command", false, ` 1 @@ -1217,9 +1227,11 @@ func TestEnqueueWindowsMDMCommand(t *testing.T) { for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - ctx = test.UserContext(ctx, test.UserAdmin) + // Use a per-subtest context so a premium license added by one case does not leak into later cases via the + // shared outer ctx (which would mask a missing premium gate). + cmdCtx := test.UserContext(ctx, test.UserAdmin) if c.premium { - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + cmdCtx = license.NewContext(cmdCtx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) } var svcImpl *Service @@ -1229,7 +1241,7 @@ func TestEnqueueWindowsMDMCommand(t *testing.T) { case *Service: svcImpl = v } - res, err := svcImpl.enqueueMicrosoftMDMCommand(ctx, []byte(c.xmlCmd), []string{"uuid"}) + res, err := svcImpl.enqueueMicrosoftMDMCommand(cmdCtx, []byte(c.xmlCmd), []string{"uuid"}) if c.wantErr != "" { require.Error(t, err) diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 5f24b73b5f..322484eef7 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -4376,11 +4376,7 @@ func executeWindowsProfileReconcileBatch( // Windows equivalent of Apple's Commander struct, but I'd like // to keep it simpler for now until we understand more. func buildCommandFromProfileBytes(profileBytes []byte, commandUUID string) (*fleet.MDMWindowsCommand, error) { - rawCommand := profileBytes - if strings.Contains(string(rawCommand), "/Vendor/MSFT/ClientCertificateInstall/SCEP") && !strings.Contains(string(rawCommand), "") { - // It's a SCEP profile, so wrap it with - rawCommand = fmt.Appendf([]byte{}, "%s", rawCommand) - } + rawCommand := fleet.WrapSCEPProfileInAtomic(profileBytes) cmds, err := fleet.UnmarshallMultiTopLevelXMLProfile(rawCommand) if err != nil { return nil, fmt.Errorf("unmarshalling profile bytes: %w", err) diff --git a/server/service/microsoft_mdm_test.go b/server/service/microsoft_mdm_test.go index c63390d02e..3a2effa630 100644 --- a/server/service/microsoft_mdm_test.go +++ b/server/service/microsoft_mdm_test.go @@ -593,6 +593,20 @@ func TestBuildCommandFromProfileBytes(t *testing.T) { string(scepCmdWithAtomic.RawCommand), ) }) + + t.Run("scope-less SCEP profile is wrapped in Atomic", func(t *testing.T) { + scepLocURI := "Vendor/MSFT/ClientCertificateInstall/SCEP/$FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID/Install/ServerURL" + cmd, err := buildCommandFromProfileBytes(syncMLForTest(scepLocURI), "uuid-scopeless") + require.NoError(t, err) + require.Contains(t, string(cmd.RawCommand), "") + + // A non-wrapped profile unmarshalls into a single top-level command; only an wrapper populates both nested + // command slices, so this is a definitive check that the scope-less SCEP profile was wrapped. + wrapped := new(fleet.SyncMLCmd) + require.NoError(t, xml.Unmarshal(cmd.RawCommand, wrapped)) + require.Len(t, wrapped.ReplaceCommands, 1) + require.Len(t, wrapped.AddCommands, 1) + }) } func syncMLForTest(locURI string) []byte { diff --git a/server/service/windows_mdm_profiles.go b/server/service/windows_mdm_profiles.go index fbd1056a1a..f7242be56f 100644 --- a/server/service/windows_mdm_profiles.go +++ b/server/service/windows_mdm_profiles.go @@ -155,7 +155,7 @@ func (svc *Service) handleWindowsProfileSoftwareUpdate( syncML []byte, teamID uint, ) error { - if !bytes.Contains(syncML, []byte(syncml.FleetOSUpdateTargetLocURI)) { + if !fleet.ProfileTargetsReservedLocURI(syncML, syncml.FleetOSUpdateTargetLocURI) { return nil }