Fix Windows CSP bypass issue (#48843)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #48752 Stacked PR. Needs 48349-windows-modify branch to merge first. # 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. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Fixed a Windows MDM loophole where scope-less or differently formatted `LocURI` values could bypass Fleet restrictions. * Strengthened detection and enforcement for reserved Windows targets, including OS updates, remote wipe premium gating, and BitLocker restrictions. * Improved `LocURI` handling to be resilient to whitespace and alternate formatting, including more consistent SCEP profile processing. * **Tests** * Added regression coverage for reserved `LocURI` matching, OS-update targeting, and premium detection for wipe commands (including scope-less cases). <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Fixed a bug where a custom Windows configuration profile/command could bypass Fleet's checks by using a scope-less LocURI.
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <Atomic> if not already.
|
||||
if strings.Contains(string(normalized), WINDOWS_SCEP_LOC_URI_PART) && !strings.Contains(string(normalized), "<Atomic>") {
|
||||
normalized = fmt.Appendf([]byte{}, "<Atomic>%s</Atomic>", 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 <Atomic> 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("<Atomic>")) {
|
||||
return fmt.Appendf([]byte{}, "<Atomic>%s</Atomic>", 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), "<Atomic>") {
|
||||
normalized = fmt.Appendf([]byte{}, "<Atomic>%s</Atomic>", 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
|
||||
}
|
||||
|
||||
@@ -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: `<Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate</LocURI></Target></Item></Replace>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "scope-less OS update profile (regression #48752)",
|
||||
syncML: `<Replace><Item><Target><LocURI>Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate</LocURI></Target></Item></Replace>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "scope-less OS update inside Atomic",
|
||||
syncML: `<Atomic><Replace><Item><Target><LocURI>Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate</LocURI></Target></Item></Replace></Atomic>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-OS-update profile",
|
||||
syncML: `<Replace><Item><Target><LocURI>Vendor/MSFT/BitLocker/RequireDeviceEncryption</LocURI></Target></Item></Replace>`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "node ending in Update is not reserved",
|
||||
syncML: `<Replace><Item><Target><LocURI>Custom/Config/UpdatePolicy/AllowAutoUpdate</LocURI></Target></Item></Replace>`,
|
||||
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: `<Replace><Item><Target><LocURI>Vendor/MSFT/Policy/Config/UpdateExtra/AllowAutoUpdate</LocURI></Target></Item></Replace>`,
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,6 +79,19 @@ func TestValidateUserProvided(t *testing.T) {
|
||||
<Target><LocURI>./Vendor/MSFT/BitLocker/Foo</LocURI></Target>
|
||||
</Item>
|
||||
</Replace>
|
||||
`),
|
||||
},
|
||||
wantErr: syncml.DiskEncryptionProfileRestrictionErrMsg,
|
||||
},
|
||||
{
|
||||
name: "Reserved LocURI with scope-less prefix",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
SyncML: []byte(`
|
||||
<Replace>
|
||||
<Item>
|
||||
<Target><LocURI>Vendor/MSFT/BitLocker/RequireDeviceEncryption</LocURI></Target>
|
||||
</Item>
|
||||
</Replace>
|
||||
`),
|
||||
},
|
||||
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(`
|
||||
<Add>
|
||||
<Item>
|
||||
<Target>
|
||||
<LocURI>Vendor/MSFT/ClientCertificateInstall/SCEP/bogus-id-that-is-not-fleet-var/Install/CAThumbprint</LocURI>
|
||||
</Target>
|
||||
</Item>
|
||||
</Add>
|
||||
`),
|
||||
},
|
||||
wantErr: fmt.Sprintf("You must use \"$FLEET_VAR_%s\" after \"ClientCertificateInstall/SCEP/\".", FleetVarSCEPWindowsCertificateID),
|
||||
},
|
||||
{
|
||||
name: "SCEP Profile with missing required LocURI",
|
||||
profile: MDMWindowsConfigProfile{
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1187,6 +1187,16 @@ func TestEnqueueWindowsMDMCommand(t *testing.T) {
|
||||
</Target>
|
||||
</Item>
|
||||
</Exec>`, "", "./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, `
|
||||
<Exec>
|
||||
<CmdID>1</CmdID>
|
||||
<Item>
|
||||
<Target>
|
||||
<LocURI>Vendor/MSFT/RemoteWipe/doWipe</LocURI>
|
||||
</Target>
|
||||
</Item>
|
||||
</Exec>`, "Requires Fleet Premium license", ""},
|
||||
{"non-premium command", false, `
|
||||
<Exec>
|
||||
<CmdID>1</CmdID>
|
||||
@@ -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)
|
||||
|
||||
@@ -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), "<Atomic>") {
|
||||
// It's a SCEP profile, so wrap it with <Atomic>
|
||||
rawCommand = fmt.Appendf([]byte{}, "<Atomic>%s</Atomic>", rawCommand)
|
||||
}
|
||||
rawCommand := fleet.WrapSCEPProfileInAtomic(profileBytes)
|
||||
cmds, err := fleet.UnmarshallMultiTopLevelXMLProfile(rawCommand)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshalling profile bytes: %w", err)
|
||||
|
||||
@@ -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), "<Atomic>")
|
||||
|
||||
// A non-wrapped profile unmarshalls into a single top-level command; only an <Atomic> 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user