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 +}