From b438893bc2918b09bf191324bd70b7466adb4476 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Fri, 26 Jun 2026 15:58:42 -0400 Subject: [PATCH] Do not block further wipe commands on inactive existing entry (#48358) **Related issue:** Resolves #45931 # 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), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [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 ## Summary by CodeRabbit * **Bug Fixes** * Improved device lock handling so only active pending lock commands are treated as valid. * Fixed stale lock state cases where an old lock reference no longer blocks a new lock request. * When a prior lock command is no longer deliverable, a new lock command is now issued and tracked correctly. * Updated coverage to verify lock status transitions and replacement behavior in these edge cases. --- server/datastore/mysql/nanomdm_storage.go | 18 ++++- .../datastore/mysql/nanomdm_storage_test.go | 68 ++++++++++++++++ .../service/integration_mdm_commands_test.go | 78 +++++++++++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index c2e0ead15a..3c318e9a83 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -191,6 +191,7 @@ func (s *NanoMDMStorage) GetPendingLockCommand(ctx context.Context, hostUUID str LEFT JOIN nano_command_results ncr ON ncr.command_uuid = nc.command_uuid INNER JOIN nano_enrollment_queue neq ON neq.command_uuid = nc.command_uuid WHERE neq.id = ? + AND neq.active = 1 AND nc.request_type = 'DeviceLock' AND ncr.command_uuid IS NULL ORDER BY nc.created_at DESC @@ -244,10 +245,21 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( `SELECT lock_ref FROM host_mdm_actions WHERE host_id = ? FOR UPDATE`, host.ID) - // If we got a row and it has a lock_ref, fail with conflict + // A non-null lock_ref only blocks a new lock if it still points to a + // deliverable command. Re-enrollment, SCEP renewal, and wipe flip the + // queued command to active=0 (see nanomdm ClearQueue), and an inactive + // command is never sent to the device, so treat it as an orphan ref and + // let the new lock overwrite it below. if err == nil && existingLockRef != nil && *existingLockRef != "" { - // A lock command already exists, don't overwrite - return lockConflictError{hostUUID: host.UUID} + var active bool + if err := sqlx.GetContext(ctx, tx, &active, + `SELECT EXISTS(SELECT 1 FROM nano_enrollment_queue WHERE command_uuid = ? AND id = ? AND active = 1)`, + *existingLockRef, host.UUID); err != nil { + return ctxerr.Wrap(ctx, err, "checking if existing lock command is active") + } + if active { + return lockConflictError{hostUUID: host.UUID} + } } // If the row doesn't exist, that's OK, we'll insert it diff --git a/server/datastore/mysql/nanomdm_storage_test.go b/server/datastore/mysql/nanomdm_storage_test.go index d3bf9c7acc..8eb71c15cb 100644 --- a/server/datastore/mysql/nanomdm_storage_test.go +++ b/server/datastore/mysql/nanomdm_storage_test.go @@ -28,6 +28,7 @@ func TestNanoMDMStorage(t *testing.T) { }{ {"TestEnqueueDeviceLockCommand", testEnqueueDeviceLockCommand}, {"TestGetPendingLockCommand", testGetPendingLockCommand}, + {"TestEnqueueDeviceLockReplacesOrphanRef", testEnqueueDeviceLockReplacesOrphanRef}, {"TestEnqueueDeviceLockCommandRaceCondition", testEnqueueDeviceLockCommandRaceCondition}, {"TestEnqueueDeviceUnlockCommand", testEnqueueDeviceUnlockCommand}, {"TestStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal", testStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal}, @@ -242,6 +243,73 @@ func testGetPendingLockCommand(t *testing.T, ds *Datastore) { require.Empty(t, pin) } +// testEnqueueDeviceLockReplacesOrphanRef verifies that a lock_ref pointing to a +// command that is no longer deliverable (nano_enrollment_queue.active = 0, e.g. +// after re-enrollment, SCEP renewal, or wipe) is treated as an orphan: it does +// not count as a pending lock and does not block a fresh lock command. +// See https://github.com/fleetdm/fleet/issues/45931 +func testEnqueueDeviceLockReplacesOrphanRef(t *testing.T, ds *Datastore) { + ctx := context.Background() + ns, err := ds.NewMDMAppleMDMStorage() + require.NoError(t, err) + + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "orphan-relock-name", + OsqueryHostID: new("4242"), + NodeKey: new("4242"), + UUID: "orphan-relock-uuid", + TeamID: nil, + Platform: "darwin", + }) + require.NoError(t, err) + nanoEnroll(t, ds, host, false) + + // Enqueue an initial lock command: active=1, no result yet -> genuinely pending. + lockCmd := &mdm.Command{} + lockCmd.CommandUUID = "orphan-lock-cmd-1" + lockCmd.Command.RequestType = "DeviceLock" + lockCmd.Raw = []byte("