diff --git a/changes/21979-do-wipe-command b/changes/21979-do-wipe-command new file mode 100644 index 0000000000..63a7efe4f9 --- /dev/null +++ b/changes/21979-do-wipe-command @@ -0,0 +1 @@ +* Extended `POST /api/v1/fleet/hosts/:id/wipe` endpoint to allow users to specify the type of remote wipe for windows hosts. \ No newline at end of file diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 89a25de3d7..d93a370efd 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -4455,9 +4455,10 @@ To wipe a macOS, iOS, iPadOS, or Windows host, the host must have MDM turned on. #### Parameters -| Name | Type | In | Description | -| ---------- | ----------------- | ---- | ----------------------------------------------------------------------------- | -| id | integer | path | **Required**. ID of the host to be wiped. | +| Name | Type | In | Description | +|----------| ----------------- | ---- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| id | integer | path | **Required**. ID of the host to be wiped. | +| windows | object | body | Optional metadata used when wiping Windows hosts. The object includes a `wipe_type` property that can be used for specifying what type of remote wipe to perform. Allowed values are `"doWipe"` and `"doWipeProtected"`. | #### Example diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index 6df42cf038..d17f109b8b 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -225,7 +225,7 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return svc.enqueueUnlockHostRequest(ctx, host, lockWipe) } -func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { +func (svc *Service) WipeHost(ctx context.Context, hostID uint, metadata *fleet.MDMWipeMetadata) error { // First ensure the user has access to list hosts, then check the specific // host once team_id is loaded. if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { @@ -317,7 +317,7 @@ func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { } // all good, go ahead with queuing the wipe request. - return svc.enqueueWipeHostRequest(ctx, host, lockWipe) + return svc.enqueueWipeHostRequest(ctx, host, lockWipe, metadata) } func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus, viewPIN bool) ( @@ -436,7 +436,12 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho return unlockPIN, nil } -func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host, wipeStatus *fleet.HostLockWipeStatus) error { +func (svc *Service) enqueueWipeHostRequest( + ctx context.Context, + host *fleet.Host, + wipeStatus *fleet.HostLockWipeStatus, + metadata *fleet.MDMWipeMetadata, +) error { vc, ok := viewer.FromContext(ctx) if !ok { return fleet.ErrNoContext @@ -450,11 +455,16 @@ func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host } case "windows": + // default wipe type + wipeType := fleet.MDMWindowsWipeTypeDoWipeProtected + if metadata != nil && metadata.Windows != nil { + wipeType = metadata.Windows.WipeType + } wipeCmdUUID := uuid.NewString() wipeCmd := &fleet.MDMWindowsCommand{ CommandUUID: wipeCmdUUID, - RawCommand: []byte(fmt.Sprintf(windowsWipeCommand, wipeCmdUUID)), - TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", + RawCommand: []byte(fmt.Sprintf(windowsWipeCommand, wipeCmdUUID, wipeType.String())), + TargetLocURI: fmt.Sprintf("./Device/Vendor/MSFT/RemoteWipe/%s", wipeType.String()), } if err := svc.ds.WipeHostViaWindowsMDM(ctx, host, wipeCmd); err != nil { return ctxerr.Wrap(ctx, err, "enqueuing wipe request for windows") @@ -506,7 +516,7 @@ var ( %s - ./Device/Vendor/MSFT/RemoteWipe/doWipeProtected + ./Device/Vendor/MSFT/RemoteWipe/%s chr diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 423f23b0a1..1a6e3ebc6d 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1005,3 +1005,8 @@ type MDMConfigProfileStatus struct { Pending uint `json:"pending" db:"pending"` Failed uint `json:"failed" db:"failed"` } + +// MDMWipeMetadata specifies optional metadata for the remote wipe command +type MDMWipeMetadata struct { + Windows *MDMWindowsWipeMetadata +} diff --git a/server/fleet/service.go b/server/fleet/service.go index 084192949a..956ebfe747 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1186,7 +1186,7 @@ type Service interface { // Script-based methods (at least for some platforms, MDM-based for others) LockHost(ctx context.Context, hostID uint, viewPIN bool) (unlockPIN string, err error) UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) - WipeHost(ctx context.Context, hostID uint) error + WipeHost(ctx context.Context, hostID uint, metadata *MDMWipeMetadata) error /////////////////////////////////////////////////////////////////////////////// // Software installers diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 5ce597e8d9..d28dd50822 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -2,6 +2,7 @@ package fleet import ( "bytes" + "encoding/json" "encoding/xml" "errors" "fmt" @@ -211,3 +212,42 @@ type MDMWindowsProfileContents struct { SyncML []byte `db:"syncml"` Checksum []byte `db:"checksum"` } + +// MDMWindowsWipeType specifies what type of remote wipe we want +// to perform. +type MDMWindowsWipeType int + +const ( + MDMWindowsWipeTypeDoWipe MDMWindowsWipeType = iota + MDMWindowsWipeTypeDoWipeProtected +) + +var wipeTypeVariants = map[MDMWindowsWipeType]string{ + MDMWindowsWipeTypeDoWipe: "doWipe", + MDMWindowsWipeTypeDoWipeProtected: "doWipeProtected", +} + +func (wt *MDMWindowsWipeType) String() string { + if wt == nil { + return "" + } + return wipeTypeVariants[*wt] +} + +func (wt *MDMWindowsWipeType) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + for k, v := range wipeTypeVariants { + if v == s { + *wt = k + return nil + } + } + return fmt.Errorf("invalid WipeType: %s", s) +} + +type MDMWindowsWipeMetadata struct { + WipeType MDMWindowsWipeType `json:"wipe_type"` +} diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index b0a7e3d558..14e2e55895 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -2002,9 +2002,9 @@ func TestLockUnlockWipeHostAuth(t *testing.T) { return &fleet.HostLockWipeStatus{}, nil } - err = svc.WipeHost(ctx, globalHostID) + err = svc.WipeHost(ctx, globalHostID, nil) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - err = svc.WipeHost(ctx, teamHostID) + err = svc.WipeHost(ctx, teamHostID, nil) checkAuthErr(t, tt.shouldFailTeamWrite, err) }) } diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index cb0db41754..1d9292f660 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -289,7 +289,7 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() { s.Do( "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), - nil, + json.RawMessage(`{ "windows": {"wipe_type": "doWipe"}}`), http.StatusOK, ) @@ -305,7 +305,7 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() { require.NotNil(t, wipeCmd) require.Equal(t, wipeCmd.Verb, fleet.CmdExec) require.Len(t, wipeCmd.Cmd.Items, 1) - require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", *wipeCmd.Cmd.Items[0].Target) + require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipe", *wipeCmd.Cmd.Items[0].Target) msgID, err := device.GetCurrentMsgID() require.NoError(t, err) diff --git a/server/service/scripts.go b/server/service/scripts.go index 68c9228fa1..a86e27c2cb 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -2,11 +2,14 @@ package service import ( "context" + "crypto/x509" + "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" + "net/url" "path/filepath" "strconv" "time" @@ -1305,12 +1308,35 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return "", fleet.ErrMissingLicense } -//////////////////////////////////////////////////////////////////////////////// +// ////////////////////////////////////////////////////////////////////////////// // Wipe host -//////////////////////////////////////////////////////////////////////////////// +// ////////////////////////////////////////////////////////////////////////////// + +func (req *wipeHostRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error { + if r == nil { + return nil + } + + decoder := json.NewDecoder(io.LimitReader(r, 100*1024)) + metadata := fleet.MDMWipeMetadata{} + if err := decoder.Decode(&metadata); err != nil { + if err == io.EOF { + // OK ... body is optional + return nil + } + return &fleet.BadRequestError{ + Message: "failed to unmarshal request body", + InternalErr: err, + } + } + req.Metadata = &metadata + + return nil +} type wipeHostRequest struct { - HostID uint `url:"id"` + HostID uint `url:"id"` + Metadata *fleet.MDMWipeMetadata } type wipeHostResponse struct { @@ -1323,14 +1349,14 @@ func (r wipeHostResponse) Error() error { return r.Err } func wipeHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*wipeHostRequest) - if err := svc.WipeHost(ctx, req.HostID); err != nil { + if err := svc.WipeHost(ctx, req.HostID, req.Metadata); err != nil { return wipeHostResponse{Err: err}, nil } // We bail if a host is locked or wiped, so we can assume the host is unlocked at this point return wipeHostResponse{DeviceStatus: fleet.DeviceStatusUnlocked, PendingAction: fleet.PendingActionWipe}, nil } -func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { +func (svc *Service) WipeHost(ctx context.Context, _ uint, _ *fleet.MDMWipeMetadata) error { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go index 3fae585ce3..54d529399d 100644 --- a/server/service/scripts_test.go +++ b/server/service/scripts_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "io" "strings" "testing" "time" @@ -968,3 +969,80 @@ func TestBatchScriptExecute(t *testing.T) { require.Equal(t, []uint{3, 4}, requestedHostIds) }) } + +func TestWipeHostRequestDecodeBody(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + body io.Reader + expectedError string + expectation func(t *testing.T, req *wipeHostRequest) + }{ + { + name: "empty body", + body: strings.NewReader(""), + expectation: func(t *testing.T, req *wipeHostRequest) { + require.Nil(t, req.Metadata) + }, + }, + { + name: "doWipe", + body: strings.NewReader(`{"windows": {"wipe_type": "doWipe"}}`), + expectation: func(t *testing.T, req *wipeHostRequest) { + require.NotNil(t, req.Metadata) + require.NotNil(t, req.Metadata.Windows) + require.Equal(t, fleet.MDMWindowsWipeTypeDoWipe, req.Metadata.Windows.WipeType) + }, + }, + { + name: "doWipeProtected", + body: strings.NewReader(`{"windows": {"wipe_type": "doWipeProtected"}}`), + expectation: func(t *testing.T, req *wipeHostRequest) { + require.NotNil(t, req.Metadata) + require.NotNil(t, req.Metadata.Windows) + require.Equal(t, fleet.MDMWindowsWipeTypeDoWipeProtected, req.Metadata.Windows.WipeType) + }, + }, + { + name: "invalid wipe type", + body: strings.NewReader(`{"windows": {"wipe_type": "doWipeProtectedII"}}`), + expectedError: "failed to unmarshal request body", + }, + { + name: "empty payload", + body: strings.NewReader(`{}`), + expectation: func(t *testing.T, req *wipeHostRequest) { + require.NotNil(t, req.Metadata) + require.Nil(t, req.Metadata.Windows) + }, + }, + { + name: "windows field is null", + body: strings.NewReader(`{"windows": null}`), + expectation: func(t *testing.T, req *wipeHostRequest) { + require.NotNil(t, req.Metadata) + require.Nil(t, req.Metadata.Windows) + }, + }, + { + name: "empty wipe type", + body: strings.NewReader(`{"windows": {"wipe_type": null}}`), + expectedError: "failed to unmarshal request body", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sut := wipeHostRequest{} + err := sut.DecodeBody(ctx, tc.body, nil, nil) + + if tc.expectedError != "" { + require.ErrorContains(t, err, tc.expectedError) + } else { + require.NoError(t, err) + tc.expectation(t, &sut) + } + }) + } +}