diff --git a/server/fleet/microsoft_mdm.go b/server/fleet/microsoft_mdm.go
index c72e37070a..2ad91adc0c 100644
--- a/server/fleet/microsoft_mdm.go
+++ b/server/fleet/microsoft_mdm.go
@@ -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
+}
diff --git a/server/fleet/service.go b/server/fleet/service.go
index c83493ad7e..e540799eaa 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -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)
}
diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go
index 590a7c8a1b..fd3fcbbeb7 100644
--- a/server/mdm/microsoft/microsoft_mdm.go
+++ b/server/mdm/microsoft/microsoft_mdm.go
@@ -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) {
diff --git a/server/service/handler.go b/server/service/handler.go
index a076b2649e..267419a0e9 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -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.
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index b15b7485dd..2604edb51b 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -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(`
+
+
+ 1.2
+ DM/1.2
+ 1
+ 1
+
+ ` + managementUrl + `
+
+
+ DB257C3A08778F4FB61E2749066C1F27
+
+
+
+
+ 2
+ 1201
+
+
+ 3
+ 1224
+ -
+
+ com.microsoft/MDM/LoginStatus
+
+ user
+
+
+
+ 4
+ -
+
+ ./DevInfo/DevId
+
+ DB257C3A08778F4FB61E2749066C1F27
+
+ -
+
+ ./DevInfo/Man
+
+ VMware, Inc.
+
+ -
+
+ ./DevInfo/Mod
+
+ VMware7,1
+
+ -
+
+ ./DevInfo/DmV
+
+ 1.3
+
+ -
+
+ ./DevInfo/Lang
+
+ en-US
+
+
+
+
+ `), nil
+}
diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go
index 7afe3abb68..674754ecf7 100644
--- a/server/service/microsoft_mdm.go
+++ b/server/service/microsoft_mdm.go
@@ -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 = `
+
+
+
+ 1.2
+ DM/1.2
+ ` + strconv.Itoa(sessionID) + `
+ ` + strconv.Itoa(msgID) + `
+
+ ` + deviceID + `
+
+
+ ` + urlManagementEndpoint + `
+
+
+
+
+ ` + getNextCmdID(&cmdID) + `
+ ` + strconv.Itoa(msgID) + `
+ 0
+ SyncHdr
+ 200
+
+
+ ` + getNextCmdID(&cmdID) + `
+ ` + strconv.Itoa(msgID) + `
+ 2
+ Alert
+ 200
+
+
+ ` + getNextCmdID(&cmdID) + `
+ ` + strconv.Itoa(msgID) + `
+ 3
+ Alert
+ 200
+
+
+ ` + getNextCmdID(&cmdID) + `
+ ` + strconv.Itoa(msgID) + `
+ 4
+ Replace
+ 200
+
+ ` + svc.getConfigProfilesToEnforce(ctx, &cmdID) + `
+
+
+ `
+ } else {
+ // Acknowledge SyncML messages sent by host
+ response = `
+
+
+
+ 1.2
+ DM/1.2
+ ` + strconv.Itoa(sessionID) + `
+ ` + strconv.Itoa(msgID) + `
+
+ ` + deviceID + `
+
+
+ ` + urlManagementEndpoint + `
+
+
+
+
+ ` + getNextCmdID(&cmdID) + `
+ ` + strconv.Itoa(msgID) + `
+ 0
+ SyncHdr
+ 200
+
+
+
+ `
+ }
+
+ // 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 := `
+
+
+
+ https://download.fleetdm.com/fleetd-base.msi
+
+
+
+ 7D127BA8F8CC5937DB3052E2632D672120217D910E271A58565BBA780ED8F05C
+
+
+ /quiet FleetURL="` + fleetEnrollUrl + `" FleetSecret="` + globalEnrollSecret + `"
+ 10
+ 1
+ 5
+
+
+ `
+
+ newCmds := `
+ ` + getNextCmdID(commandID) + `
+ -
+
+ ./Device/Vendor/MSFT/EnterpriseDesktopAppManagement/MSI/%7Bf5645004-3214-46ea-92c2-48835689da06%7D/DownloadInstall
+
+
+
+
+ ` + getNextCmdID(commandID) + `
+ -
+
+ ./Device/Vendor/MSFT/EnterpriseDesktopAppManagement/MSI/%7Bf5645004-3214-46ea-92c2-48835689da06%7D/DownloadInstall
+
+ ` + html.EscapeString(installCommandPayload) + `
+
+ text/plain
+ xml
+
+
+ `
+
+ 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
+}