Adding temporary MS-MDM implementation (#12852)

This is the prototype implementation for MS-MDM. Most of the code here
will change in the upcoming sprints once
https://github.com/fleetdm/fleet/issues/12839,
https://github.com/fleetdm/fleet/issues/12840,
https://github.com/fleetdm/fleet/issues/12841 get implemented.

- [ ] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [X] Added/updated tests
- [X] Manual QA for all new/changed functionality
This commit is contained in:
Marcos Oviedo 2023-07-20 11:54:04 -03:00 committed by GitHub
parent d6f51f893c
commit 2c02ab3be5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 521 additions and 2 deletions

View file

@ -823,3 +823,79 @@ type MDMWindowsEnrolledDevice struct {
func (e MDMWindowsEnrolledDevice) AuthzType() string {
return "mdm_windows"
}
///////////////////////////////////////////////////////////////
/// Microsoft MS-MDM message
type SyncMLMessage struct {
XMLinfo string `xml:"xmlns,attr"`
Header SyncMLHeader `xml:"SyncHdr"`
Body SyncMLBody `xml:"SyncBody"`
}
// SyncML XML Parsing Types - This needs to be improved
type SyncMLHeader struct {
DTD string `xml:"VerDTD"`
Version string `xml:"VerProto"`
SessionID int `xml:"SessionID"`
MsgID int `xml:"MsgID"`
Target string `xml:"Target>LocURI"`
Source string `xml:"Source>LocURI"`
MaxMsgSize int `xml:"Meta>A:MaxMsgSize"`
}
type SyncMLCommandMeta struct {
XMLinfo string `xml:"xmlns,attr"`
Type string `xml:"Type"`
}
type SyncMLCommandItem struct {
Meta SyncMLCommandMeta `xml:"Meta"`
Source string `xml:"Source>LocURI"`
Data string `xml:"Data"`
}
type SyncMLCommand struct {
XMLName xml.Name
CmdID int `xml:",omitempty"`
MsgRef string `xml:",omitempty"`
CmdRef string `xml:",omitempty"`
Cmd string `xml:",omitempty"`
Target string `xml:"Target>LocURI"`
Source string `xml:"Source>LocURI"`
Data string `xml:",omitempty"`
Item []SyncMLCommandItem `xml:",any"`
}
type SyncMLBody struct {
Item []SyncMLCommand `xml:",any"`
}
// IsValidSyncMLMsg checks for required fields in the SyncML message
func (req *SyncMLMessage) IsValidSyncMLMsg() error {
if req == nil {
return errors.New("invalid SyncML message: nil")
}
if len(req.Header.Version) == 0 {
return errors.New("invalid SyncML message: Version")
}
if len(req.Header.Target) == 0 {
return errors.New("invalid SyncML message: Target")
}
if req.Header.SessionID == 0 {
return errors.New("invalid SyncML message: SessionID")
}
if req.Header.MsgID == 0 {
return errors.New("invalid SyncML message: SessionID")
}
if len(req.Body.Item) == 0 {
return errors.New("invalid SyncML message: Item")
}
return nil
}

View file

@ -777,4 +777,7 @@ type Service interface {
// SignMDMMicrosoftClientCSR returns a signed certificate from the client certificate signing request and the
// certificate fingerprint. The certificate common name should be passed in the subject parameter.
SignMDMMicrosoftClientCSR(ctx context.Context, subject string, csr *x509.CertificateRequest) ([]byte, string, error)
// GetMDMWindowsManagementResponse returns a valid SyncML response message
GetMDMWindowsManagementResponse(ctx context.Context, reqSyncML *SyncMLMessage) (*string, error)
}

View file

@ -135,6 +135,9 @@ const (
// HTTP Content Type for SOAP responses
SoapContentType = "application/soap+xml; charset=utf-8"
// HTTP Content Type for SyncML MDM responses
SyncMLContentType = "application/vnd.syncml.dm+xml"
// Minimal Key Length for SHA1WithRSA encryption
PolicyMinKeyLength = "2048"
@ -228,6 +231,10 @@ const (
// Login related query param expected by STS Auth endpoint
STSLoginHint = "login_hint"
// Alert Command IDs
DeviceUnenrollmentID = "1226"
HostInitMessageID = "1201"
)
func ResolveWindowsMDMDiscovery(serverURL string) (string, error) {

View file

@ -610,6 +610,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// This endpoint is authenticated using the BinarySecurityToken header field
neWindowsMDM.POST(microsoft_mdm.MDE2EnrollPath, mdmMicrosoftEnrollEndpoint, SoapRequestContainer{})
// This endpoint is unauthenticated for now
// It should be authenticated through TLS headers once proper implementation is in place
neWindowsMDM.POST(microsoft_mdm.MDE2ManagementPath, mdmMicrosoftManagementEndpoint, SyncMLReqMsgContainer{})
ne.POST("/api/fleet/orbit/enroll", enrollOrbitEndpoint, EnrollOrbitRequest{})
// For some reason osquery does not provide a node key with the block data.

View file

@ -6170,6 +6170,36 @@ func (s *integrationMDMTestSuite) TestInvalidGetAuthRequest() {
require.Contains(t, resContent, "forbidden")
}
func (s *integrationMDMTestSuite) TestValidSyncMLRequestNoAuth() {
t := s.T()
// Target Endpoint URL for the management endpoint
targetEndpointURL := microsoft_mdm.MDE2ManagementPath
// Preparing the SyncML request
requestBytes, err := s.newSyncMLSessionMsg(targetEndpointURL)
require.NoError(t, err)
resp := s.DoRaw("POST", targetEndpointURL, requestBytes, http.StatusOK)
resBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, resp.Header["Content-Type"], microsoft_mdm.SyncMLContentType)
// Checking if SyncML response can be unmarshalled to an golang type
var xmlType interface{}
err = xml.Unmarshal(resBytes, &xmlType)
require.NoError(t, err)
// Checking if SOAP response contains a valid RequestSecurityTokenResponseCollection message
resSoapMsg := string(resBytes)
require.True(t, s.isXMLTagPresent("SyncHdr", resSoapMsg))
require.True(t, s.isXMLTagPresent("SyncBody", resSoapMsg))
require.True(t, s.isXMLTagContentPresent("Exec", resSoapMsg))
require.True(t, s.isXMLTagContentPresent("Add", resSoapMsg))
}
// ///////////////////////////////////////////////////////////////////////////
// Common helpers
@ -6356,3 +6386,76 @@ func (s *integrationMDMTestSuite) newSecurityTokenMsg(encodedBinToken string, de
return requestBytes, nil
}
// TODO: Add support to add custom DeviceID when DeviceAuth is in place
func (s *integrationMDMTestSuite) newSyncMLSessionMsg(managementUrl string) ([]byte, error) {
if len(managementUrl) == 0 {
return nil, errors.New("managementUrl is empty")
}
return []byte(`
<SyncML xmlns="SYNCML:SYNCML1.2">
<SyncHdr>
<VerDTD>1.2</VerDTD>
<VerProto>DM/1.2</VerProto>
<SessionID>1</SessionID>
<MsgID>1</MsgID>
<Target>
<LocURI>` + managementUrl + `</LocURI>
</Target>
<Source>
<LocURI>DB257C3A08778F4FB61E2749066C1F27</LocURI>
</Source>
</SyncHdr>
<SyncBody>
<Alert>
<CmdID>2</CmdID>
<Data>1201</Data>
</Alert>
<Alert>
<CmdID>3</CmdID>
<Data>1224</Data>
<Item>
<Meta>
<Type xmlns="syncml:metinf">com.microsoft/MDM/LoginStatus</Type>
</Meta>
<Data>user</Data>
</Item>
</Alert>
<Replace>
<CmdID>4</CmdID>
<Item>
<Source>
<LocURI>./DevInfo/DevId</LocURI>
</Source>
<Data>DB257C3A08778F4FB61E2749066C1F27</Data>
</Item>
<Item>
<Source>
<LocURI>./DevInfo/Man</LocURI>
</Source>
<Data>VMware, Inc.</Data>
</Item>
<Item>
<Source>
<LocURI>./DevInfo/Mod</LocURI>
</Source>
<Data>VMware7,1</Data>
</Item>
<Item>
<Source>
<LocURI>./DevInfo/DmV</LocURI>
</Source>
<Data>1.3</Data>
</Item>
<Item>
<Source>
<LocURI>./DevInfo/Lang</LocURI>
</Source>
<Data>en-US</Data>
</Item>
</Replace>
<Final/>
</SyncBody>
</SyncML>`), nil
}

View file

@ -9,11 +9,13 @@ import (
"encoding/xml"
"errors"
"fmt"
"html"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -67,7 +69,7 @@ func (r SoapResponseContainer) error() error { return r.Err }
func (r SoapResponseContainer) hijackRender(ctx context.Context, w http.ResponseWriter) {
xmlRes, err := xml.MarshalIndent(r.Data, "", "\t")
if err != nil {
logging.WithExtras(ctx, "Windows MDM SoapResponseContainer", err)
logging.WithExtras(ctx, "error with SoapResponseContainer", err)
w.WriteHeader(http.StatusBadRequest)
return
}
@ -82,6 +84,56 @@ func (r SoapResponseContainer) hijackRender(ctx context.Context, w http.Response
}
}
type SyncMLReqMsgContainer struct {
Data *fleet.SyncMLMessage
Params url.Values
Err error
}
// MDM SOAP request decoder
func (req *SyncMLReqMsgContainer) DecodeBody(ctx context.Context, r io.Reader, u url.Values) error {
// Reading the request bytes
reqBytes, err := io.ReadAll(r)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading SyncML message request")
}
// Set the request parameters
req.Params = u
// Handle empty body scenario
req.Data = &fleet.SyncMLMessage{}
if len(reqBytes) != 0 {
// Unmarshal the XML data from the request into the SoapRequest struct
err = xml.Unmarshal(reqBytes, &req.Data)
if err != nil {
return ctxerr.Wrap(ctx, err, "unmarshalling SyncML message request")
}
}
return nil
}
type SyncMLResponseMsgContainer struct {
Data *string
Err error
}
func (r SyncMLResponseMsgContainer) error() error { return r.Err }
// hijackRender writes the response header and the RAW HTML output
func (r SyncMLResponseMsgContainer) hijackRender(ctx context.Context, w http.ResponseWriter) {
resData := []byte(*r.Data + "\n")
w.Header().Set("Content-Type", mdm.SyncMLContentType)
w.Header().Set("Content-Length", strconv.Itoa(len(resData)))
w.WriteHeader(http.StatusOK)
if n, err := w.Write(resData); err != nil {
logging.WithExtras(ctx, "err", err, "written", n)
}
}
type MDMAuthContainer struct {
Data *string
Err error
@ -764,6 +816,33 @@ func mdmMicrosoftEnrollEndpoint(ctx context.Context, request interface{}, svc fl
}, nil
}
// mdmMicrosoftManagementEndpoint handles the OMA DM management sessions
// It receives a SyncML message with protocol commands, it process the commands and responds with a
// SyncML message with protocol commands results and more protocol commands for the calling host
// Note: This logic needs to be improved with better SyncML message parsing, better message tracking
// and better security authentication (done through TLS and in-message hash)
func mdmMicrosoftManagementEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
reqSyncML := request.(*SyncMLReqMsgContainer).Data
// Checking first if incoming SyncML message is valid and returning error if this is not the case
if err := reqSyncML.IsValidSyncMLMsg(); err != nil {
soapFault := svc.GetAuthorizedSoapFault(ctx, mdm.SoapErrorMessageFormat, mdm_types.MDEFault, err)
return getSoapResponseFault(strconv.Itoa(reqSyncML.Header.MsgID), soapFault), nil
}
// Getting the RequestSecurityTokenResponseCollection message
resSyncML, err := svc.GetMDMWindowsManagementResponse(ctx, reqSyncML)
if err != nil {
soapFault := svc.GetAuthorizedSoapFault(ctx, mdm.SoapErrorMessageFormat, mdm_types.MDEEnrollment, err)
return getSoapResponseFault(strconv.Itoa(reqSyncML.Header.MsgID), soapFault), nil
}
return SyncMLResponseMsgContainer{
Data: resSyncML,
Err: nil,
}, nil
}
// authBinarySecurityToken checks if the provided token is valid
func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *fleet.HeaderBinarySecurityToken) (string, error) {
if authToken == nil {
@ -974,7 +1053,7 @@ func (svc *Service) GetMDMWindowsEnrollResponse(ctx context.Context, secTokenMsg
return nil, ctxerr.Wrap(ctx, err, "device enroll check")
}
// Getting the the device provisioning information in the form of a WapProvisioningDoc
// Getting the device provisioning information in the form of a WapProvisioningDoc
deviceProvisioning, err := svc.getDeviceProvisioningInformation(ctx, secTokenMsg)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "device provisioning information")
@ -1006,6 +1085,152 @@ func (svc *Service) GetMDMWindowsEnrollResponse(ctx context.Context, secTokenMsg
return &secTokenResponseCollectionMsg, nil
}
// GetMDMWindowsManagementResponse returns a valid SyncML response message
func (svc *Service) GetMDMWindowsManagementResponse(ctx context.Context, reqSyncML *fleet.SyncMLMessage) (*string, error) {
if reqSyncML == nil {
return nil, fleet.NewInvalidArgumentError("syncml req message", "message is not present")
}
// TODO: The following logic should happen here
// - TLS based auth
// - Device auth based on Source/LocURI DeviceID information
// (this should be present on Enrollment DB)
// - Processing of incoming protocol commands (Alerts mostly
// - MS-MDM session management
// - Inclusion of queued protocol commands should be performed here
// - Tracking of message acknowledgements through Message queue
// Getting the management response message
resSyncMLmsg, err := svc.getManagementResponse(ctx, reqSyncML)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "device provisioning information")
}
// Token is authorized
svc.authz.SkipAuthorization(ctx)
return resSyncMLmsg, nil
}
func (svc *Service) getManagementResponse(ctx context.Context, reqSyncML *fleet.SyncMLMessage) (*string, error) {
if reqSyncML == nil {
return nil, fleet.NewInvalidArgumentError("syncml req message", "message is not present")
}
// cmdID tracks the command sequence
cmdID := 0
// Retrieve the MessageID from the syncml req body
deviceID := reqSyncML.Header.Source
// Retrieve the sessionID from the syncml req body
sessionID := reqSyncML.Header.SessionID
// Retrieve the msgID from the syncml req body
msgID := reqSyncML.Header.MsgID
// Getting the management URL message content
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
urlManagementEndpoint, err := mdm.ResolveWindowsMDMManagement(appCfg.ServerSettings.ServerURL)
if err != nil {
return nil, err
}
// Checking the SyncML message types
var response string
if isSessionInitializationMessage(reqSyncML.Body) {
// Create response payload - MDM SyncML configuration profiles commands will be enforced here
response = `
<?xml version="1.0" encoding="UTF-8"?>
<SyncML xmlns="SYNCML:SYNCML1.2">
<SyncHdr>
<VerDTD>1.2</VerDTD>
<VerProto>DM/1.2</VerProto>
<SessionID>` + strconv.Itoa(sessionID) + `</SessionID>
<MsgID>` + strconv.Itoa(msgID) + `</MsgID>
<Target>
<LocURI>` + deviceID + `</LocURI>
</Target>
<Source>
<LocURI>` + urlManagementEndpoint + `</LocURI>
</Source>
</SyncHdr>
<SyncBody>
<Status>
<CmdID>` + getNextCmdID(&cmdID) + `</CmdID>
<MsgRef>` + strconv.Itoa(msgID) + `</MsgRef>
<CmdRef>0</CmdRef>
<Cmd>SyncHdr</Cmd>
<Data>200</Data>
</Status>
<Status>
<CmdID>` + getNextCmdID(&cmdID) + `</CmdID>
<MsgRef>` + strconv.Itoa(msgID) + `</MsgRef>
<CmdRef>2</CmdRef>
<Cmd>Alert</Cmd>
<Data>200</Data>
</Status>
<Status>
<CmdID>` + getNextCmdID(&cmdID) + `</CmdID>
<MsgRef>` + strconv.Itoa(msgID) + `</MsgRef>
<CmdRef>3</CmdRef>
<Cmd>Alert</Cmd>
<Data>200</Data>
</Status>
<Status>
<CmdID>` + getNextCmdID(&cmdID) + `</CmdID>
<MsgRef>` + strconv.Itoa(msgID) + `</MsgRef>
<CmdRef>4</CmdRef>
<Cmd>Replace</Cmd>
<Data>200</Data>
</Status>
` + svc.getConfigProfilesToEnforce(ctx, &cmdID) + `
<Final />
</SyncBody>
</SyncML>`
} else {
// Acknowledge SyncML messages sent by host
response = `
<?xml version="1.0" encoding="UTF-8"?>
<SyncML xmlns="SYNCML:SYNCML1.2">
<SyncHdr>
<VerDTD>1.2</VerDTD>
<VerProto>DM/1.2</VerProto>
<SessionID>` + strconv.Itoa(sessionID) + `</SessionID>
<MsgID>` + strconv.Itoa(msgID) + `</MsgID>
<Target>
<LocURI>` + deviceID + `</LocURI>
</Target>
<Source>
<LocURI>` + urlManagementEndpoint + `</LocURI>
</Source>
</SyncHdr>
<SyncBody>
<Status>
<CmdID>` + getNextCmdID(&cmdID) + `</CmdID>
<MsgRef>` + strconv.Itoa(msgID) + `</MsgRef>
<CmdRef>0</CmdRef>
<Cmd>SyncHdr</Cmd>
<Data>200</Data>
</Status>
<Final />
</SyncBody>
</SyncML>`
}
// Create a replacer to replace both "\n" and "\t"
replacer := strings.NewReplacer("\n", "", "\t", "")
// Use the replacer on the string representation of xmlContent
responseRaw := replacer.Replace(response)
return &responseRaw, nil
}
// removeWindowsDeviceIfAlreadyMDMEnrolled removes the device if already MDM enrolled
// HW DeviceID is used to check the list of enrolled devices
func (svc *Service) removeWindowsDeviceIfAlreadyMDMEnrolled(ctx context.Context, secTokenMsg *fleet.RequestSecurityToken) error {
@ -1230,3 +1455,104 @@ func (svc *Service) SignMDMMicrosoftClientCSR(ctx context.Context, subject strin
return cert, fpHex, nil
}
func (svc *Service) getConfigProfilesToEnforce(ctx context.Context, commandID *int) string {
// fleetctl package
// --fleet-url=https://dashboard.fleetdm.ngrok.dev
// --enroll-secret=6EM269jFhXlEcWn9nr/kCQGNa5sIh3GM
// Getting the management URL
appCfg, _ := svc.ds.AppConfig(ctx)
fleetEnrollUrl := appCfg.ServerSettings.ServerURL
// Getting the global enrollment secret
var globalEnrollSecret string
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
if err != nil {
return ""
}
for _, secret := range secrets {
if secret.TeamID == nil {
globalEnrollSecret = secret.Secret
break
}
}
// keeping the same GUID will prevent the MSI to be installed multiple times - it will be
// installed only the first time the message is issued.
// FleetURL and FleetSecret properties are passed to the Fleet MSI
// See here for more information: https://learn.microsoft.com/en-us/windows/win32/msi/command-line-options
installCommandPayload := `<MsiInstallJob id="{f5645004-3214-46ea-92c2-48835689da06}">
<Product Version="1.0.0.0">
<Download>
<ContentURLList>
<ContentURL>https://download.fleetdm.com/fleetd-base.msi</ContentURL>
</ContentURLList>
</Download>
<Validation>
<FileHash>7D127BA8F8CC5937DB3052E2632D672120217D910E271A58565BBA780ED8F05C</FileHash>
</Validation>
<Enforcement>
<CommandLine>/quiet FleetURL="` + fleetEnrollUrl + `" FleetSecret="` + globalEnrollSecret + `"</CommandLine>
<TimeOut>10</TimeOut>
<RetryCount>1</RetryCount>
<RetryInterval>5</RetryInterval>
</Enforcement>
</Product>
</MsiInstallJob>`
newCmds := `<Add>
<CmdID>` + getNextCmdID(commandID) + `</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/EnterpriseDesktopAppManagement/MSI/%7Bf5645004-3214-46ea-92c2-48835689da06%7D/DownloadInstall</LocURI>
</Target>
</Item>
</Add>
<Exec>
<CmdID>` + getNextCmdID(commandID) + `</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/EnterpriseDesktopAppManagement/MSI/%7Bf5645004-3214-46ea-92c2-48835689da06%7D/DownloadInstall</LocURI>
</Target>
<Data>` + html.EscapeString(installCommandPayload) + `</Data>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">xml</Format>
</Meta>
</Item>
</Exec>`
return newCmds
}
// getNextCmdID returns the next command ID
func getNextCmdID(i *int) string {
*i++
return strconv.Itoa(*i)
}
// Checks if body contains a DM device unrollment SyncML message
func isDeviceUnenrollmentMessage(body fleet.SyncMLBody) bool {
for _, element := range body.Item {
if element.Data == mdm.DeviceUnenrollmentID {
return true
}
}
return false
}
// Checks if body contains a DM session initialization SyncML message sent by device
func isSessionInitializationMessage(body fleet.SyncMLBody) bool {
isUnenrollMessage := isDeviceUnenrollmentMessage(body)
for _, element := range body.Item {
if element.Data == mdm.HostInitMessageID && !isUnenrollMessage {
return true
}
}
return false
}