diff --git a/changes/issue-12261-microsoft-mdm-discovery-endpoint b/changes/issue-12261-microsoft-mdm-discovery-endpoint new file mode 100644 index 000000000..805a2e490 --- /dev/null +++ b/changes/issue-12261-microsoft-mdm-discovery-endpoint @@ -0,0 +1 @@ +* Microsoft MDM Enrollment Protocol: Added support for the DiscoveryRequest messages diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 8d014a661..fde38e4d8 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -898,7 +898,7 @@ spec: emptySetupAsst := writeTmpJSON(t, map[string]any{}) // Apply global config with custom setting and macos setup assistant, and enable - // Windows MDM. + // Microsoft MDM. name = writeTmpYml(t, fmt.Sprintf(`--- apiVersion: v1 kind: config @@ -929,7 +929,7 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{mobileConfigPath}, }, - WindowsEnabledAndConfigured: true, + MicrosoftEnabledAndConfigured: true, }, currentAppConfig.MDM) // start a server to return the bootstrap package @@ -962,7 +962,7 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{mobileConfigPath}, }, - WindowsEnabledAndConfigured: true, + MicrosoftEnabledAndConfigured: true, }, currentAppConfig.MDM) // Apply team config. diff --git a/docs/Using-Fleet/configuration-files/README.md b/docs/Using-Fleet/configuration-files/README.md index 4fe3cbf07..26e89ccbb 100644 --- a/docs/Using-Fleet/configuration-files/README.md +++ b/docs/Using-Fleet/configuration-files/README.md @@ -1379,7 +1379,7 @@ Set name of default team to use with Apple Business Manager. ##### mdm.windows_enabled_and_configured -Enables or disables Windows MDM support. +Enables or disables Microsoft MDM support. - Default value: false - Config file format: diff --git a/orbit/pkg/table/mdm/mdm_windows.go b/orbit/pkg/table/mdm/mdm_windows.go index 7732b23ca..9c66e76eb 100644 --- a/orbit/pkg/table/mdm/mdm_windows.go +++ b/orbit/pkg/table/mdm/mdm_windows.go @@ -398,7 +398,7 @@ func executeMDMcommand(inputCMD string) (string, error) { return "", fmt.Errorf("there was an error calling ApplyLocalManagementSyncML(): (0x%X)", err, returnCode) } - // converting windows MDM UTF16 output string into go string + // converting Microsoft MDM UTF16 output string into go string outputCmd, err := localUTF16toString(unsafe.Pointer(outputStrBuffer)) if err != nil { return "", err diff --git a/server/config/config.go b/server/config/config.go index 0059e4396..f63c7db67 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1659,11 +1659,11 @@ func SetTestMDMConfig(t testing.TB, cfg *FleetConfig, cert, key []byte, appleBMT cfg.MDM.AppleSCEPChallenge = "testchallenge" } -// Undocumented feature flag for Windows MDM, used to determine if the Windows +// Undocumented feature flag for Microsoft MDM, used to determine if the Windows // MDM feature is visible in the UI and can be enabled. More details here: // https://github.com/fleetdm/fleet/issues/12257 // -// TODO: remove this flag once the Windows MDM feature is ready for +// TODO: remove this flag once the Microsoft MDM feature is ready for // release. func IsMDMFeatureFlagEnabled() bool { return os.Getenv("FLEET_DEV_MDM_ENABLED") == "1" diff --git a/server/fleet/app.go b/server/fleet/app.go index cc63f1251..0882b037f 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -151,11 +151,11 @@ type MDM struct { MacOSMigration MacOSMigration `json:"macos_migration"` EndUserAuthentication MDMEndUserAuthentication `json:"end_user_authentication"` - // WindowsEnabledAndConfigured indicates if Fleet MDM is enabled for Windows. + // MicrosoftEnabledAndConfigured indicates if Fleet MDM is enabled for Windows. // There is no other configuration required for Windows other than enabling // the support, but it is still called "EnabledAndConfigured" for consistency // with the similarly named macOS-specific fields. - WindowsEnabledAndConfigured bool `json:"windows_enabled_and_configured"` + MicrosoftEnabledAndConfigured bool `json:"windows_enabled_and_configured"` ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 3cc63a271..68d2a0907 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -555,9 +555,9 @@ func (h *Host) NeedsDEPEnrollment() bool { h.IsDEPAssignedToFleet() } -// IsElegibleForWindowsMDMEnrollment returns true if the host can be enrolled -// in Fleet's Windows MDM (if Windows MDM was enabled). -func (h *Host) IsElegibleForWindowsMDMEnrollment() bool { +// IsElegibleForMicrosoftMDMEnrollment returns true if the host can be enrolled +// in Fleet's Microsoft MDM (if Microsoft MDM was enabled). +func (h *Host) IsElegibleForMicrosoftMDMEnrollment() bool { return h.FleetPlatform() == "windows" && h.IsOsqueryEnrolled() && !h.MDMInfo.IsEnrolledInThirdPartyMDM() && diff --git a/server/fleet/microsoft_mdm.go b/server/fleet/microsoft_mdm.go new file mode 100644 index 000000000..d878ac8ec --- /dev/null +++ b/server/fleet/microsoft_mdm.go @@ -0,0 +1,343 @@ +package fleet + +import ( + "encoding/xml" +) + +// MS-MDE2 Message request types +const ( + MDEDiscovery = iota + MDEPolicy + MDEEnrollment + MDEFault +) + +/////////////////////////////////////////////////////////////// +/// Microsoft MS-MDE2 SOAP types + +// ResponseHeader is the header for MDM responses from the server +type ResponseHeader struct { + Action Action `xml:"Action"` + RelatesTo string `xml:"a:RelatesTo"` + ActivityId *ActivityId `xml:"ActivityId,omitempty"` + Security *WsSecurity `xml:"o:Security,omitempty"` +} + +// RequestHeader is the header for MDM requests to the server +type RequestHeader struct { + Action Action `xml:"Action"` + MessageID string `xml:"MessageID"` + ReplyTo ReplyTo `xml:"ReplyTo"` + To To `xml:"To"` + Security *TokenSecurity `xml:"Security,omitempty"` +} + +// BodyReponse is the body of the MDM SOAP response message +type BodyResponse struct { + Xsd *string `xml:"xmlns:xsd,attr,omitempty"` + Xsi *string `xml:"xmlns:xsi,attr,omitempty"` + DiscoverResponse *DiscoverResponse `xml:"DiscoverResponse,omitempty"` + GetPoliciesResponse *GetPoliciesResponse `xml:"GetPoliciesResponse,omitempty"` + RequestSecurityTokenResponseCollection *RequestSecurityTokenResponseCollection `xml:"RequestSecurityTokenResponseCollection,omitempty"` + SoapFault *SoapFault `xml:"s:fault,omitempty"` +} + +// BodyRequest is the body of the MDM SOAP request message +type BodyRequest struct { + Xsi *string `xml:"xsi,attr,omitempty"` + Xsd *string `xml:"xsd,attr,omitempty"` + Discover *Discover `xml:"Discover,omitempty"` + GetPolicies *GetPolicies `xml:"GetPolicies,omitempty"` + RequestSecurityToken *RequestSecurityToken `xml:"RequestSecurityToken,omitempty"` +} + +// HTTP request header field used to indicate the intent of the SOAP request, using a URI value +// See section 6.1.1 on SOAP Spec - https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383527 +type Action struct { + Content string `xml:",chardata"` + MustUnderstand string `xml:"mustUnderstand,attr"` +} + +// ActivityId is a unique identifier for the activity +type ActivityId struct { + Content string `xml:",chardata"` + CorrelationId string `xml:"CorrelationId,attr"` + XmlNS string `xml:"xmlns,attr"` +} + +// Timestamp for certificate authentication +type Timestamp struct { + ID string `xml:"u:Id,attr"` + Created string `xml:"u:Created"` + Expires string `xml:"u:Expires"` +} + +// Security token container +type WsSecurity struct { + XmlNS string `xml:"xmlns:o,attr"` + MustUnderstand string `xml:"s:mustUnderstand,attr"` + Timestamp Timestamp `xml:"u:Timestamp"` +} + +// Security token container for encoded security sensitive data +type BinSecurityToken struct { + Content string `xml:",chardata"` + Value string `xml:"ValueType,attr"` + Encoding string `xml:"EncodingType,attr"` +} + +// TokenSecurity is the security token container for BinSecurityToken +type TokenSecurity struct { + MustUnderstand string `xml:"mustUnderstand,attr"` + Security BinSecurityToken `xml:"BinarySecurityToken"` +} + +// To target endpoint header field +type To struct { + Content string `xml:",chardata"` + MustUnderstand string `xml:"mustUnderstand,attr"` +} + +// ReplyTo message correlation header field +type ReplyTo struct { + Address string `xml:"Address"` +} + +/////////////////////////////////////////////////////////////// +/// Discover MS-MDE2 Message request type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/36e33def-59ab-484f-b0bc-701496346925 + +type Discover struct { + XmlNS string `xml:"xmlns,attr"` + Request DiscoverRequest `xml:"request"` +} + +type AuthPolicies struct { + AuthPolicy []string `xml:"AuthPolicy"` +} + +type DiscoverRequest struct { + XmlNS string `xml:"i,attr"` + EmailAddress string `xml:"EmailAddress"` + RequestVersion string `xml:"RequestVersion"` + DeviceType string `xml:"DeviceType"` + ApplicationVersion string `xml:"ApplicationVersion"` + OSEdition string `xml:"OSEdition"` + AuthPolicies AuthPolicies `xml:"AuthPolicies"` +} + +/////////////////////////////////////////////////////////////// +/// GetPolicies MS-MDE2 Message request type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/02b080e4-d1d8-4e0c-af14-b77931cec404 + +type GetPolicies struct { + XmlNS string `xml:"xmlns,attr"` + Client Client `xml:"client"` + RequestFilter RequestFilter `xml:"requestFilter"` +} + +type ClientContent struct { + Content string `xml:",chardata"` + Xsi string `xml:"nil,attr"` +} + +type Client struct { + LastUpdate ClientContent `xml:"lastUpdate"` + PreferredLanguage ClientContent `xml:"preferredLanguage"` +} + +type RequestFilter struct { + Xsi string `xml:"nil,attr"` +} + +/////////////////////////////////////////////////////////////// +/// RequestSecurityToken MS-MDE2 Message request type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/6ba9c509-8bce-4899-85b2-8c3d41f8f845 + +type RequestSecurityToken struct { + TokenType string `xml:"TokenType"` + RequestType string `xml:"RequestType"` + BinarySecurityToken BinarySecurityToken `xml:"BinarySecurityToken"` + AdditionalContext AdditionalContext `xml:"AdditionalContext"` +} + +type BinarySecurityToken struct { + Content string `xml:",chardata"` + XmlNS *string `xml:"xmlns,attr"` + ValueType string `xml:"ValueType,attr"` + EncodingType string `xml:"EncodingType,attr"` +} + +type ContextItem struct { + Name string `xml:"Name,attr"` + Value string `xml:"Value"` +} + +type AdditionalContext struct { + XmlNS string `xml:"xmlns,attr"` + ContextItem []ContextItem `xml:"ContextItem"` +} + +/////////////////////////////////////////////////////////////// +/// DiscoverResponse MS-MDE2 Message response type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/aa198049-e691-41f9-a45a-b973b9089be7 + +type DiscoverResponse struct { + XMLName xml.Name `xml:"DiscoverResponse"` + XmlNS string `xml:"xmlns,attr"` + DiscoverResult DiscoverResult `xml:"DiscoverResult"` +} + +type DiscoverResult struct { + AuthPolicy string `xml:"AuthPolicy"` + EnrollmentVersion string `xml:"EnrollmentVersion"` + EnrollmentPolicyServiceUrl string `xml:"EnrollmentPolicyServiceUrl"` + EnrollmentServiceUrl string `xml:"EnrollmentServiceUrl"` +} + +/////////////////////////////////////////////////////////////// +/// GetPoliciesResponse MS-MDE2 Message response type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/6e74dcdb-c3d9-4044-af10-536224904e72 + +type GetPoliciesResponse struct { + XMLName xml.Name `xml:"GetPoliciesResponse"` + XmlNS string `xml:"xmlns,attr"` + Response Response `xml:"response"` + OIDs OIDs `xml:"oIDs"` +} + +type ContentAttr struct { + Content string `xml:",chardata"` + Xsi string `xml:"xsi:nil,attr"` + XmlNS string `xml:"xmlns:xsi,attr"` +} + +type GenericAttr struct { + Xsi string `xml:"xsi:nil,attr"` +} + +type CertificateValidity struct { + ValidityPeriodSeconds string `xml:"validityPeriodSeconds"` + RenewalPeriodSeconds string `xml:"renewalPeriodSeconds"` +} + +type Permission struct { + Enroll string `xml:"enroll"` + AutoEnroll string `xml:"autoEnroll"` +} + +type PrivateKeyAttributes struct { + MinimalKeyLength string `xml:"minimalKeyLength"` + KeySpec GenericAttr `xml:"keySpec"` + KeyUsageProperty GenericAttr `xml:"keyUsageProperty"` + Permissions GenericAttr `xml:"permissions"` + AlgorithmOIDReference GenericAttr `xml:"algorithmOIDReference"` + CryptoProviders GenericAttr `xml:"cryptoProviders"` +} +type Revision struct { + MajorRevision string `xml:"majorRevision"` + MinorRevision string `xml:"minorRevision"` +} + +type Attributes struct { + CommonName string `xml:"commonName"` + PolicySchema string `xml:"policySchema"` + CertificateValidity CertificateValidity `xml:"certificateValidity"` + Permission Permission `xml:"permission"` + PrivateKeyAttributes PrivateKeyAttributes `xml:"privateKeyAttributes"` + Revision Revision `xml:"revision"` + SupersededPolicies GenericAttr `xml:"supersededPolicies"` + PrivateKeyFlags GenericAttr `xml:"privateKeyFlags"` + SubjectNameFlags GenericAttr `xml:"subjectNameFlags"` + EnrollmentFlags GenericAttr `xml:"enrollmentFlags"` + GeneralFlags GenericAttr `xml:"generalFlags"` + HashAlgorithmOIDReference string `xml:"hashAlgorithmOIDReference"` + RARequirements GenericAttr `xml:"rARequirements"` + KeyArchivalAttributes GenericAttr `xml:"keyArchivalAttributes"` + Extensions GenericAttr `xml:"extensions"` +} + +type GPPolicy struct { + PolicyOIDReference string `xml:"policyOIDReference"` + CAs GenericAttr `xml:"cAs"` + Attributes Attributes `xml:"attributes"` +} + +type Policies struct { + Policy GPPolicy `xml:"policy"` +} + +type Response struct { + PolicyID string `xml:"policyID"` + PolicyFriendlyName ContentAttr `xml:"policyFriendlyName"` + NextUpdateHours ContentAttr `xml:"nextUpdateHours"` + PoliciesNotChanged ContentAttr `xml:"policiesNotChanged"` + Policies Policies `xml:"policies"` +} + +type OID struct { + Value string `xml:"value"` + Group string `xml:"group"` + OIDReferenceID string `xml:"oIDReferenceID"` + DefaultName string `xml:"defaultName"` +} + +type OIDs struct { + Content string `xml:",chardata"` + OID OID `xml:"oID"` +} + +/////////////////////////////////////////////////////////////// +/// RequestSecurityTokenResponseCollection MS-MDE2 Message response type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/3452fe7d-2441-49f7-8801-c056b58edb6a + +type RequestSecurityTokenResponseCollection struct { + XMLName xml.Name `xml:"RequestSecurityTokenResponseCollection"` + XmlNS string `xml:"xmlns,attr"` + RequestSecurityTokenResponse RequestSecurityTokenResponse `xml:"RequestSecurityTokenResponse"` +} + +type SecAttr struct { + Content string `xml:",chardata"` + XmlNS string `xml:"xmlns,attr"` +} + +type RequestedSecurityToken struct { + BinarySecurityToken BinarySecurityToken `xml:"BinarySecurityToken"` +} + +type RequestSecurityTokenResponse struct { + TokenType string `xml:"TokenType"` + DispositionMessage SecAttr `xml:"DispositionMessage"` + RequestedSecurityToken RequestedSecurityToken `xml:"RequestedSecurityToken"` + RequestID SecAttr `xml:"RequestID"` +} + +/////////////////////////////////////////////////////////////// +/// SoapFault MS-MDE2 Message response type +/// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/0a78f419-5fd7-4ddb-bc76-1c0f7e11da23 + +type Subcode struct { + Value string `xml:"s:value"` +} + +type Code struct { + Value string `xml:"s:value"` + Subcode Subcode `xml:"s:subcode"` +} + +type ReasonText struct { + Content string `xml:",chardata"` + Lang string `xml:"xml:lang,attr"` +} + +type Reason struct { + Text ReasonText `xml:"s:text"` +} + +type SoapFault struct { + XMLName xml.Name `xml:"s:fault"` + Code Code `xml:"s:code"` + Reason Reason `xml:"s:reason"` + OriginalMessageType int `xml:"-"` +} diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 5abb12d6b..bd6ec1ac5 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -6,11 +6,11 @@ import "encoding/json" // fleetd (orbit) so that it can run commands or more generally react to this // information. type OrbitConfigNotifications struct { - RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"` - RotateDiskEncryptionKey bool `json:"rotate_disk_encryption_key,omitempty"` - NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"` - NeedsProgrammaticWindowsMDMEnrollment bool `json:"needs_programmatic_windows_mdm_enrollment,omitempty"` - WindowsMDMDiscoveryEndpoint string `json:"windows_mdm_discovery_endpoint,omitempty"` + RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"` + RotateDiskEncryptionKey bool `json:"rotate_disk_encryption_key,omitempty"` + NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"` + NeedsProgrammaticMicrosoftMDMEnrollment bool `json:"needs_programmatic_microsoft_mdm_enrollment,omitempty"` + MicrosoftMDMDiscoveryEndpoint string `json:"microsoft_mdm_discovery_endpoint,omitempty"` } type OrbitConfig struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 0b1e6c57d..83ef484dd 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -696,6 +696,11 @@ type Service interface { // error can be raised to the user. VerifyMDMAppleConfigured(ctx context.Context) error + // VerifyMDMMicrosoftConfigured verifies that the server is configured for + // Microsoft MDM. If an error is returned, authorization is skipped so the + // error can be raised to the user. + VerifyMDMMicrosoftConfigured(ctx context.Context) error + MDMAppleUploadBootstrapPackage(ctx context.Context, name string, pkg io.Reader, teamID uint) error GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*MDMAppleBootstrapPackage, error) @@ -746,4 +751,13 @@ type Service interface { ResetAutomation(ctx context.Context, teamIDs, policyIDs []uint) error RequestEncryptionKeyRotation(ctx context.Context, hostID uint) error + + /////////////////////////////////////////////////////////////////////////////// + // Microsoft MDM + + // GetMDMMicrosoftDiscoveryResponse returns a valid DiscoveryResponse message + GetMDMMicrosoftDiscoveryResponse(ctx context.Context) (*DiscoverResponse, error) + + // GetAuthorizedSoapFault authorize the request so SoapFault message can be returned + GetAuthorizedSoapFault(ctx context.Context, eType string, origMsg int, errorMsg error) *SoapFault } diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go new file mode 100644 index 000000000..598da399c --- /dev/null +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -0,0 +1,124 @@ +package microsoft_mdm + +import "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" + +const ( + // MDMPath is Fleet's HTTP path for the core Microsoft MDM service. + MDMPath = "/api/mdm/microsoft" + + // DiscoveryPath is the HTTP endpoint path that serves the IDiscoveryService functionality. + // This is the endpoint that process the Discover and DiscoverResponse messages + // See the section 3.1 on the MS-MDE2 specification for more details: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/2681fd76-1997-4557-8963-cf656ab8d887 + MDE2DiscoveryPath = MDMPath + "/discovery" + + // MDE2PolicyPath is the HTTP endpoint path that delivers the X.509 Certificate Enrollment Policy (MS-XCEP) functionality. + // This is the endpoint that process the GetPolicies and GetPoliciesResponse messages + // See the section 3.3 on the MS-MDE2 specification for more details on this endpoint requirements: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/8a5efdf8-64a9-44fd-ab63-071a26c9f2dc + // The MS-XCEP specification is available here: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xcep/08ec4475-32c2-457d-8c27-5a176660a210 + MDE2PolicyPath = MDMPath + "/policy" + + // MDE2EnrollPath is the HTTP endpoint path that delivers WS-Trust X.509v3 Token Enrollment (MS-WSTEP) functionality. + // This is the endpoint that process the RequestSecurityToken and RequestSecurityTokenResponseCollection messages + // See the section 3.4 on the MS-MDE2 specification for more details on this endpoint requirements: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/5b02c625-ced2-4a01-a8e1-da0ae84f5bb7 + // The MS-WSTEP specification is available here: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wstep/4766a85d-0d18-4fa1-a51f-e5cb98b752ea + MDE2EnrollPath = MDMPath + "/enroll" + + // MDE2ManagementPath is the HTTP endpoint path that delivers WS-Trust X.509v3 Token Enrollment (MS-WSTEP) functionality. + // This is the endpoint that process the RequestSecurityToken and RequestSecurityTokenResponseCollection messages + // See the section 3.4 on the MS-MDE2 specification for more details: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/5b02c625-ced2-4a01-a8e1-da0ae84f5bb7 + MDE2ManagementPath = MDMPath + "/management" + + // These are the entry points for the Microsoft Device Enrollment (MS-MDE) and Microsoft Device Enrollment v2 (MS-MDE2) protocols. + // These are required to be implemented by the MDM server to support user-driven enrollments + MSEnrollEntryPoint = "/EnrollmentServer/Discovery.svc" + MSManageEntryPoint = "/ManagementServer/MDM.svc" +) + +// XML Namespaces used by the Microsoft Device Enrollment v2 protocol (MS-MDE2) +const ( + DiscoverNS = "http://schemas.microsoft.com/windows/management/2012/01/enrollment" + PolicyNS = "http://schemas.microsoft.com/windows/pki/2009/01/enrollmentpolicy" + EnrollWSTrust = "http://docs.oasis-open.org/ws-sx/ws-trust/200512" + EnrollSecExt = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" + EnrollTType = "http://schemas.microsoft.com/5.0.0.0/ConfigurationManager/Enrollment/DeviceEnrollmentToken" + EnrollPDoc = "http://schemas.microsoft.com/5.0.0.0/ConfigurationManager/Enrollment/DeviceEnrollmentProvisionDoc" + EnrollEncode = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd#base64binary" + EnrollReq = "http://schemas.microsoft.com/windows/pki/2009/01/enrollment" + EnrollNSS = "http://www.w3.org/2003/05/soap-envelope" + EnrollNSA = "http://www.w3.org/2005/08/addressing" + EnrollXSI = "http://www.w3.org/2001/XMLSchema-instance" + EnrollXSD = "http://www.w3.org/2001/XMLSchema" + EnrollXSU = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" + ActionNsDiag = "http://schemas.microsoft.com/2004/09/ServiceModel/Diagnostics" + ActionNsDiscovery = "http://schemas.microsoft.com/windows/management/2012/01/enrollment/IDiscoveryService/DiscoverResponse" + ActionNsPolicy = "http://schemas.microsoft.com/windows/pki/2009/01/enrollmentpolicy/IPolicy/GetPoliciesResponse" + ActionNsEnroll = "http://schemas.microsoft.com/windows/pki/2009/01/enrollment/RSTRC/wstep" +) + +// Soap Error constants +// Details here: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde2/0a78f419-5fd7-4ddb-bc76-1c0f7e11da23 + +const ( + // Message format is bad + SoapErrorMessageFormat = "s:messageformat" + + // User not recognized + SoapErrorAuthentication = "s:authentication" + + // User not allowed to enroll + SoapErrorAuthorization = "s:authorization" + + // Failed to get certificate + SoapErrorCertificateRequest = "s:certificaterequest" + + // Generic failure from management server, such as a database access error + SoapErrorEnrollmentServer = "s:enrollmentserver" + + // The server hit an unexpected issue + SoapErrorInternalServiceFault = "s:internalservicefault" + + // Cannot parse the security header + SoapErrorInvalidSecurity = "a:invalidsecurity" +) + +// MS-MDE2 Message constants +const ( + // Minimum supported version + MinEnrollmentVersion = "4.0" + + // Maximum supported version + MaxEnrollmentVersion = "5.0" + + // xsi:nil indicates value is not present + DefaultStateXSI = "true" + + // Supported authentication types + AuthOnPremise = "OnPremise" + + // SOAP Fault codes + SoapFaultRecv = "s:receiver" + + // SOAP Fault default error locale + SoapFaultLocale = "en-us" + + // HTTP Content Type for SOAP responses + SoapContentType = "application/soap+xml; charset=utf-8" +) + +func ResolveMicrosoftMDMDiscovery(serverURL string) (string, error) { + return commonmdm.ResolveURL(serverURL, MDE2DiscoveryPath, false) +} + +func ResolveMicrosoftMDMPolicy(serverURL string) (string, error) { + return commonmdm.ResolveURL(serverURL, MDE2PolicyPath, false) +} + +func ResolveMicrosoftMDMEnroll(serverURL string) (string, error) { + return commonmdm.ResolveURL(serverURL, MDE2EnrollPath, false) +} diff --git a/server/mdm/windows/windows_mdm.go b/server/mdm/windows/windows_mdm.go deleted file mode 100644 index b61f88100..000000000 --- a/server/mdm/windows/windows_mdm.go +++ /dev/null @@ -1,12 +0,0 @@ -package windows_mdm - -import "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" - -const ( - // DiscoveryPath is Fleet's HTTP path for the Windows MDM Discovery endpoint. - DiscoveryPath = "/EnrollmentServer/Discovery.svc" -) - -func ResolveWindowsMDMDiscovery(serverURL string) (string, error) { - return commonmdm.ResolveURL(serverURL, DiscoveryPath, false) -} diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 830149182..4be0f4cda 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -52,11 +52,11 @@ type appConfigResponseFields struct { // MDMEnabled is true if fleet serve was started with // FLEET_DEV_MDM_ENABLED=1. // - // Undocumented feature flag for Windows MDM, used to determine if the - // Windows MDM feature is visible in the UI and can be enabled. More details + // Undocumented feature flag for Microsoft MDM, used to determine if the + // Microsoft MDM feature is visible in the UI and can be enabled. More details // here: https://github.com/fleetdm/fleet/issues/12257 // - // TODO: remove this flag once the Windows MDM feature is ready for + // TODO: remove this flag once the Microsoft MDM feature is ready for // release. MDMEnabled bool `json:"mdm_enabled,omitempty"` } @@ -648,7 +648,7 @@ func (svc *Service) validateMDM( // Windows validation if !config.IsMDMFeatureFlagEnabled() { - if mdm.WindowsEnabledAndConfigured { + if mdm.MicrosoftEnabledAndConfigured { invalid.Append("mdm.windows_enabled_and_configured", "cannot enable Windows MDM without the feature flag explicitly enabled") return } diff --git a/server/service/handler.go b/server/service/handler.go index 0a210919d..8187cff62 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -37,6 +37,8 @@ import ( "github.com/throttled/throttled/v2" "go.elastic.co/apm/module/apmgorilla/v2" otmiddleware "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux" + + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" ) type errorHandler struct { @@ -443,52 +445,52 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any public/token-authenticated // endpoints using `neMDM` below in this file. - mdmConfiguredMiddleware := mdmconfigured.NewAppleMiddleware(svc) - mdm := ue.WithCustomMiddleware(mdmConfiguredMiddleware.Verify()) - mdm.POST("/api/_version_/fleet/mdm/apple/enqueue", enqueueMDMAppleCommandEndpoint, enqueueMDMAppleCommandRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/commandresults", getMDMAppleCommandResultsEndpoint, getMDMAppleCommandResultsRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/commands", listMDMAppleCommandsEndpoint, listMDMAppleCommandsRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/filevault/summary", getMdmAppleFileVaultSummaryEndpoint, getMDMAppleFileVaultSummaryRequest{}) - mdm.POST("/api/_version_/fleet/mdm/apple/profiles", newMDMAppleConfigProfileEndpoint, newMDMAppleConfigProfileRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesEndpoint, listMDMAppleConfigProfilesRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", getMDMAppleConfigProfileEndpoint, getMDMAppleConfigProfileRequest{}) - mdm.DELETE("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", deleteMDMAppleConfigProfileEndpoint, deleteMDMAppleConfigProfileRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryEndpoint, getMDMAppleProfilesSummaryRequest{}) - mdm.POST("/api/_version_/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantEndpoint, createMDMAppleSetupAssistantRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/enrollment_profile", getMDMAppleSetupAssistantEndpoint, getMDMAppleSetupAssistantRequest{}) - mdm.DELETE("/api/_version_/fleet/mdm/apple/enrollment_profile", deleteMDMAppleSetupAssistantEndpoint, deleteMDMAppleSetupAssistantRequest{}) + mdmConfiguredMiddleware := mdmconfigured.NewMDMConfigMiddleware(svc) + mdmAppleMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/enqueue", enqueueMDMAppleCommandEndpoint, enqueueMDMAppleCommandRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/commandresults", getMDMAppleCommandResultsEndpoint, getMDMAppleCommandResultsRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/commands", listMDMAppleCommandsEndpoint, listMDMAppleCommandsRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/filevault/summary", getMdmAppleFileVaultSummaryEndpoint, getMDMAppleFileVaultSummaryRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles", newMDMAppleConfigProfileEndpoint, newMDMAppleConfigProfileRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesEndpoint, listMDMAppleConfigProfilesRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", getMDMAppleConfigProfileEndpoint, getMDMAppleConfigProfileRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", deleteMDMAppleConfigProfileEndpoint, deleteMDMAppleConfigProfileRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryEndpoint, getMDMAppleProfilesSummaryRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantEndpoint, createMDMAppleSetupAssistantRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/enrollment_profile", getMDMAppleSetupAssistantEndpoint, getMDMAppleSetupAssistantRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/enrollment_profile", deleteMDMAppleSetupAssistantEndpoint, deleteMDMAppleSetupAssistantRequest{}) // TODO: are those undocumented endpoints still needed? I think they were only used // by 'fleetctl apple-mdm' sub-commands. - mdm.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{}) - mdm.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{}) // bootstrap-package routes - mdm.POST("/api/_version_/fleet/mdm/apple/bootstrap", uploadBootstrapPackageEndpoint, uploadBootstrapPackageRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}/metadata", bootstrapPackageMetadataEndpoint, bootstrapPackageMetadataRequest{}) - mdm.DELETE("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}", deleteBootstrapPackageEndpoint, deleteBootstrapPackageRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/bootstrap/summary", getMDMAppleBootstrapPackageSummaryEndpoint, getMDMAppleBootstrapPackageSummaryRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/bootstrap", uploadBootstrapPackageEndpoint, uploadBootstrapPackageRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}/metadata", bootstrapPackageMetadataEndpoint, bootstrapPackageMetadataRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}", deleteBootstrapPackageEndpoint, deleteBootstrapPackageRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/bootstrap/summary", getMDMAppleBootstrapPackageSummaryEndpoint, getMDMAppleBootstrapPackageSummaryRequest{}) // host-specific mdm routes - mdm.PATCH("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/unenroll", mdmAppleCommandRemoveEnrollmentProfileEndpoint, mdmAppleCommandRemoveEnrollmentProfileRequest{}) - mdm.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) - mdm.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/lock", deviceLockEndpoint, deviceLockRequest{}) - mdm.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/wipe", deviceWipeEndpoint, deviceWipeRequest{}) + mdmAppleMW.PATCH("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/unenroll", mdmAppleCommandRemoveEnrollmentProfileEndpoint, mdmAppleCommandRemoveEnrollmentProfileRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/lock", deviceLockEndpoint, deviceLockRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/wipe", deviceWipeEndpoint, deviceWipeRequest{}) - mdm.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{}) - mdm.PATCH("/api/_version_/fleet/mdm/apple/setup", updateMDMAppleSetupEndpoint, updateMDMAppleSetupRequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple", getAppleMDMEndpoint, nil) + mdmAppleMW.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{}) + mdmAppleMW.PATCH("/api/_version_/fleet/mdm/apple/setup", updateMDMAppleSetupEndpoint, updateMDMAppleSetupRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple", getAppleMDMEndpoint, nil) - mdm.POST("/api/_version_/fleet/mdm/apple/setup/eula", createMDMAppleEULAEndpoint, createMDMAppleEULARequest{}) - mdm.GET("/api/_version_/fleet/mdm/apple/setup/eula/metadata", getMDMAppleEULAMetadataEndpoint, getMDMAppleEULAMetadataRequest{}) - mdm.DELETE("/api/_version_/fleet/mdm/apple/setup/eula/{token}", deleteMDMAppleEULAEndpoint, deleteMDMAppleEULARequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/setup/eula", createMDMAppleEULAEndpoint, createMDMAppleEULARequest{}) + mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/setup/eula/metadata", getMDMAppleEULAMetadataEndpoint, getMDMAppleEULAMetadataRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/setup/eula/{token}", deleteMDMAppleEULAEndpoint, deleteMDMAppleEULARequest{}) - mdm.POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{}) - mdm.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{}) + mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{}) // the following set of mdm endpoints must always be accessible (even // if MDM is not configured) as it bootstraps the setup of MDM @@ -531,7 +533,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ).GET("/api/_version_/fleet/device/{token}/transparency", transparencyURL, transparencyURLRequest{}) // mdm-related endpoints available via device authentication - demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.Verify()) + demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) demdm.WithCustomMiddleware( errorLimiter.Limit("get_device_mdm", desktopQuota), ).GET("/api/_version_/fleet/device/{token}/mdm/apple/manual_enrollment_profile", getDeviceMDMManualEnrollProfileEndpoint, getDeviceMDMManualEnrollProfileRequest{}) @@ -583,13 +585,20 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // `service.mdmAppleConfigurationRequiredEndpoints` when you add an // endpoint that's behind the mdmConfiguredMiddleware, this applies // both to this set of endpoints and to any user authenticated - // endpoints using `mdm.*` above in this file. - neMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.Verify()) - neMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{}) - neMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{}) - neMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{}) - neMDM.GET("/api/_version_/fleet/mdm/apple/bootstrap", downloadBootstrapPackageEndpoint, downloadBootstrapPackageRequest{}) - neMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMAppleEULAEndpoint, getMDMAppleEULARequest{}) + // endpoints using `mdmAppleMW.*` above in this file. + neAppleMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) + neAppleMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{}) + neAppleMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{}) + neAppleMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{}) + neAppleMDM.GET("/api/_version_/fleet/mdm/apple/bootstrap", downloadBootstrapPackageEndpoint, downloadBootstrapPackageRequest{}) + neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMAppleEULAEndpoint, getMDMAppleEULARequest{}) + + // These endpoint are used by Microsoft devices during MDM device enrollment phase + neMicrosoftMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyMicrosoftMDM()) + + // Microsoft Device Enrollment Discovery Endpoint + // This endpoint is unauthenticated and is used by Microsoft devices to discover the MDM server + neMicrosoftMDM.POST(microsoft_mdm.MDE2DiscoveryPath, mdmMicrosoftDiscoveryEndpoint, SoapRequest{}) ne.POST("/api/fleet/orbit/enroll", enrollOrbitEndpoint, EnrollOrbitRequest{}) @@ -640,10 +649,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC errorLimiter.Limit("ping_orbit", desktopQuota), ).HEAD("/api/fleet/orbit/ping", orbitPingEndpoint, orbitPingRequest{}) - neMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). + neAppleMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). POST("/api/_version_/fleet/mdm/sso", initiateMDMAppleSSOEndpoint, initiateMDMAppleSSORequest{}) - neMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). + neAppleMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). POST("/api/_version_/fleet/mdm/sso/callback", callbackMDMAppleSSOEndpoint, callbackMDMAppleSSORequest{}) } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ee3aae4f0..1e5000d22 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4876,7 +4876,7 @@ func (s *integrationTestSuite) TestAppConfig() { "mdm": { "apple_bm_default_team": "xyz" } }`), http.StatusUnprocessableEntity, &acResp) - // try to enable windows mdm, impossible without the feature flag + // try to enable Microsoft mdm, impossible without the feature flag // (only set in mdm integrations tests) res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_enabled_and_configured": true } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index ec09fbfde..127df859d 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -2491,6 +2491,7 @@ func (s *integrationEnterpriseTestSuite) TestAppleMDMNotConfigured() { if route.deviceAuthenticated { path = fmt.Sprintf(route.path, tkn) } + res := s.Do(route.method, path, nil, expectedErr.StatusCode()) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, expectedErr.Error()) @@ -2793,13 +2794,13 @@ func (s *integrationEnterpriseTestSuite) TestOrbitConfigNudgeSettings() { // missing orbit key s.DoJSON("POST", "/api/fleet/orbit/config", nil, http.StatusUnauthorized, &resp) - // nudge config is empty if macos_updates is not set, and windows mdm notifications are unset + // nudge config is empty if macos_updates is not set, and Microsoft mdm notifications are unset h := createOrbitEnrolledHost(t, "darwin", "h", s.ds) resp = orbitGetConfigResponse{} s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) require.Empty(t, resp.NudgeConfig) - require.False(t, resp.Notifications.NeedsProgrammaticWindowsMDMEnrollment) - require.Empty(t, resp.Notifications.WindowsMDMDiscoveryEndpoint) + require.False(t, resp.Notifications.NeedsProgrammaticMicrosoftMDMEnrollment) + require.Empty(t, resp.Notifications.MicrosoftMDMDiscoveryEndpoint) // set macos_updates s.applyConfig([]byte(` diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 490fb362f..72e5870b8 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "encoding/xml" "errors" "fmt" "io" @@ -15,6 +16,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -30,7 +32,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" - windows_mdm "github.com/fleetdm/fleet/v4/server/mdm/windows" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/mock" "github.com/fleetdm/fleet/v4/server/service/schedule" @@ -227,8 +229,8 @@ func (s *integrationMDMTestSuite) TearDownTest() { "mdm": { "macos_settings": { "enable_disk_encryption": false } } }`), http.StatusOK) } - if appCfg.MDM.WindowsEnabledAndConfigured { - // ensure windows MDM is disabled on exit + if appCfg.MDM.MicrosoftEnabledAndConfigured { + // ensure microsoft MDM is disabled on exit s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_enabled_and_configured": false } }`), http.StatusOK) @@ -1730,83 +1732,6 @@ func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() { s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp) } -func (s *integrationMDMTestSuite) TestAppConfigWindowsMDM() { - ctx := context.Background() - t := s.T() - - // the feature flag is enabled for the MDM test suite - var acResp appConfigResponse - s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - assert.True(t, acResp.MDMEnabled) - assert.False(t, acResp.MDM.WindowsEnabledAndConfigured) - - // create a couple teams - tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "1"}) - require.NoError(t, err) - tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"}) - require.NoError(t, err) - - // create some hosts - a Windows workstation in each team and no-team, - // Windows server in no team, Windows workstation enrolled in a 3rd-party in - // team 2, Windows workstation already enrolled in Fleet in no team, and a - // macOS host in no team. - metadataHosts := []struct { - os string - suffix string - isServer bool - teamID *uint - enrolledName string - shouldEnroll bool - }{ - {"windows", "win-no-team", false, nil, "", true}, - {"windows", "win-team-1", false, &tm1.ID, "", true}, - {"windows", "win-team-2", false, &tm2.ID, "", true}, - {"windows", "win-server", true, nil, "", false}, // is a server - {"windows", "win-third-party", false, &tm2.ID, fleet.WellKnownMDMSimpleMDM, false}, // is enrolled in 3rd-party - {"windows", "win-fleet", false, nil, fleet.WellKnownMDMFleet, false}, // is already Fleet-enrolled - {"darwin", "macos-no-team", false, nil, "", false}, // is not Windows - } - hostsBySuffix := make(map[string]*fleet.Host, len(metadataHosts)) - for _, meta := range metadataHosts { - h := createOrbitEnrolledHost(t, meta.os, meta.suffix, s.ds) - createDeviceTokenForHost(t, s.ds, h.ID, meta.suffix) - err := s.ds.SetOrUpdateMDMData(ctx, h.ID, meta.isServer, meta.enrolledName != "", "https://example.com", false, meta.enrolledName) - require.NoError(t, err) - if meta.teamID != nil { - err = s.ds.AddHostsToTeam(ctx, meta.teamID, []uint{h.ID}) - require.NoError(t, err) - } - hostsBySuffix[meta.suffix] = h - } - - // enable Windows MDM - s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "windows_enabled_and_configured": true } - }`), http.StatusOK, &acResp) - assert.True(t, acResp.MDM.WindowsEnabledAndConfigured) - - // get the orbit config for each host, verify that only the expected ones - // receive the "needs enrollment to Windows MDM" notification. - for _, meta := range metadataHosts { - var resp orbitGetConfigResponse - s.DoJSON("POST", "/api/fleet/orbit/config", - json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hostsBySuffix[meta.suffix].OrbitNodeKey)), - http.StatusOK, &resp) - require.Equal(t, meta.shouldEnroll, resp.Notifications.NeedsProgrammaticWindowsMDMEnrollment) - if meta.shouldEnroll { - require.Contains(t, resp.Notifications.WindowsMDMDiscoveryEndpoint, windows_mdm.DiscoveryPath) - } else { - require.Empty(t, resp.Notifications.WindowsMDMDiscoveryEndpoint) - } - } - - // disable Windows MDM - s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "windows_enabled_and_configured": false } - }`), http.StatusOK, &acResp) - assert.False(t, acResp.MDM.WindowsEnabledAndConfigured) -} - func (s *integrationMDMTestSuite) TestAppConfigMDMAppleProfiles() { t := s.T() @@ -5088,6 +5013,196 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { checkMigrationResponses(host, token) } +// /////////////////////////////////////////////////////////////////////////// +// Microsoft MDM tests + +func (s *integrationMDMTestSuite) TestAppConfigMicrosoftMDM() { + ctx := context.Background() + t := s.T() + + // the feature flag is enabled for the MDM test suite + var acResp appConfigResponse + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + assert.True(t, acResp.MDMEnabled) + assert.False(t, acResp.MDM.MicrosoftEnabledAndConfigured) + + // create a couple teams + tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "1"}) + require.NoError(t, err) + tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"}) + require.NoError(t, err) + + // create some hosts - a Windows workstation in each team and no-team, + // Windows server in no team, Windows workstation enrolled in a 3rd-party in + // team 2, Windows workstation already enrolled in Fleet in no team, and a + // macOS host in no team. + metadataHosts := []struct { + os string + suffix string + isServer bool + teamID *uint + enrolledName string + shouldEnroll bool + }{ + {"windows", "win-no-team", false, nil, "", true}, + {"windows", "win-team-1", false, &tm1.ID, "", true}, + {"windows", "win-team-2", false, &tm2.ID, "", true}, + {"windows", "win-server", true, nil, "", false}, // is a server + {"windows", "win-third-party", false, &tm2.ID, fleet.WellKnownMDMSimpleMDM, false}, // is enrolled in 3rd-party + {"windows", "win-fleet", false, nil, fleet.WellKnownMDMFleet, false}, // is already Fleet-enrolled + {"darwin", "macos-no-team", false, nil, "", false}, // is not Windows + } + hostsBySuffix := make(map[string]*fleet.Host, len(metadataHosts)) + for _, meta := range metadataHosts { + h := createOrbitEnrolledHost(t, meta.os, meta.suffix, s.ds) + createDeviceTokenForHost(t, s.ds, h.ID, meta.suffix) + err := s.ds.SetOrUpdateMDMData(ctx, h.ID, meta.isServer, meta.enrolledName != "", "https://example.com", false, meta.enrolledName) + require.NoError(t, err) + if meta.teamID != nil { + err = s.ds.AddHostsToTeam(ctx, meta.teamID, []uint{h.ID}) + require.NoError(t, err) + } + hostsBySuffix[meta.suffix] = h + } + + // enable Microsoft MDM + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_enabled_and_configured": true } + }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.MicrosoftEnabledAndConfigured) + + // get the orbit config for each host, verify that only the expected ones + // receive the "needs enrollment to Microsoft MDM" notification. + for _, meta := range metadataHosts { + var resp orbitGetConfigResponse + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hostsBySuffix[meta.suffix].OrbitNodeKey)), + http.StatusOK, &resp) + require.Equal(t, meta.shouldEnroll, resp.Notifications.NeedsProgrammaticMicrosoftMDMEnrollment) + if meta.shouldEnroll { + require.Contains(t, resp.Notifications.MicrosoftMDMDiscoveryEndpoint, microsoft_mdm.MDE2DiscoveryPath) + } else { + require.Empty(t, resp.Notifications.MicrosoftMDMDiscoveryEndpoint) + } + } + + // disable Microsoft MDM + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_enabled_and_configured": false } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.MicrosoftEnabledAndConfigured) +} + +func (s *integrationMDMTestSuite) TestValidDiscoveryRequest() { + appConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + appConf.MDM.MicrosoftEnabledAndConfigured = true + err = s.ds.SaveAppConfig(context.Background(), appConf) + require.NoError(s.T(), err) + + // Preparing the Discovery Request message + requestBytes := []byte(` + + + http://schemas.microsoft.com/windows/management/2012/01/enrollment/IDiscoveryService/Discover + urn:uuid:148132ec-a575-4322-b01b-6172a9cf8478 + + http://www.w3.org/2005/08/addressing/anonymous + + https://mdmwindows.com:443/EnrollmentServer/Discovery.svc + + + + + demo@mdmwindows.com + 5.0 + CIMClient_Windows + 6.2.9200.2965 + 48 + + OnPremise + Federated + + + + + `) + + resp := s.DoRaw("POST", microsoft_mdm.MDE2DiscoveryPath, requestBytes, http.StatusOK) + + resBytes, err := io.ReadAll(resp.Body) + require.NoError(s.T(), err) + + require.Contains(s.T(), resp.Header["Content-Type"], microsoft_mdm.SoapContentType) + + // Checking if SOAP response can be unmarshalled to an golang type + var xmlType interface{} + err = xml.Unmarshal(resBytes, &xmlType) + require.NoError(s.T(), err) + + // Checking if SOAP response contains a valid DiscoveryResponse message + resSoapMsg := string(resBytes) + require.True(s.T(), s.isXMLTagContentPresent("AuthPolicy", resSoapMsg)) + require.True(s.T(), s.isXMLTagContentPresent("EnrollmentVersion", resSoapMsg)) + require.True(s.T(), s.isXMLTagContentPresent("EnrollmentPolicyServiceUrl", resSoapMsg)) + require.True(s.T(), s.isXMLTagContentPresent("EnrollmentServiceUrl", resSoapMsg)) +} + +func (s *integrationMDMTestSuite) TestInvalidDiscoveryRequest() { + appConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + appConf.MDM.MicrosoftEnabledAndConfigured = true + err = s.ds.SaveAppConfig(context.Background(), appConf) + require.NoError(s.T(), err) + + // Preparing the Discovery Request message + requestBytes := []byte(` + + + http://schemas.microsoft.com/windows/management/2012/01/enrollment/IDiscoveryService/Discover + + http://www.w3.org/2005/08/addressing/anonymous + + https://mdmwindows.com:443/EnrollmentServer/Discovery.svc + + + + + demo@mdmwindows.com + 5.0 + CIMClient_Windows + 6.2.9200.2965 + 48 + + OnPremise + Federated + + + + + `) + + resp := s.DoRaw("POST", microsoft_mdm.MDE2DiscoveryPath, requestBytes, http.StatusOK) + + resBytes, err := io.ReadAll(resp.Body) + require.NoError(s.T(), err) + + require.Contains(s.T(), resp.Header["Content-Type"], microsoft_mdm.SoapContentType) + + // Checking if response can be unmarshalled to an golang type + var xmlType interface{} + err = xml.Unmarshal(resBytes, &xmlType) + require.NoError(s.T(), err) + + // Checking if SOAP response contains a valid SoapFault message + resSoapMsg := string(resBytes) + require.True(s.T(), s.isXMLTagContentPresent("s:value", resSoapMsg)) + require.True(s.T(), s.isXMLTagContentPresent("s:text", resSoapMsg)) +} + +// /////////////////////////////////////////////////////////////////////////// +// Common helpers + func (s *integrationMDMTestSuite) runWorker() { err := s.worker.ProcessJobs(context.Background()) require.NoError(s.T(), err) @@ -5095,3 +5210,13 @@ func (s *integrationMDMTestSuite) runWorker() { require.NoError(s.T(), err) require.Empty(s.T(), pending) } + +func (s *integrationMDMTestSuite) isXMLTagContentPresent(xmlTag string, payload string) bool { + regex := fmt.Sprintf("<%s.*>(.+)", xmlTag, xmlTag) + matched, err := regexp.MatchString(regex, payload) + if err != nil { + return false + } + + return matched +} diff --git a/server/service/mdm.go b/server/service/mdm.go index a720cba2b..068d0c2c4 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -382,3 +382,25 @@ func (svc *Service) MDMAppleDeleteEULA(ctx context.Context, token string) error return fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// Microsoft MDM Middleware +//////////////////////////////////////////////////////////////////////////////// + +func (svc *Service) VerifyMDMMicrosoftConfigured(ctx context.Context) error { + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return err + } + + // Microsoft MDM configuration setting + if !appCfg.MDM.MicrosoftEnabledAndConfigured { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + return fleet.ErrMDMNotConfigured + } + + return nil +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 1b155452b..f32043a0a 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -129,3 +129,51 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { ds.AppConfigFuncInvoked = false require.False(t, authzCtx.Checked()) } + +// TODO: update this test with the correct config option +func TestVerifyMDMMicrosoftConfigured(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + cfg := config.TestConfig() + svc, baseCtx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + // mdm not configured + authzCtx := &authz_ctx.AuthorizationContext{} + ctx := authz_ctx.NewContext(baseCtx, authzCtx) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil + } + + err := svc.VerifyMDMMicrosoftConfigured(ctx) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + + // error retrieving app config + authzCtx = &authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(baseCtx, authzCtx) + testErr := errors.New("test err") + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return nil, testErr + } + + err = svc.VerifyMDMMicrosoftConfigured(ctx) + require.ErrorIs(t, err, testErr) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.True(t, authzCtx.Checked()) + + // mdm configured + authzCtx = &authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(baseCtx, authzCtx) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{MicrosoftEnabledAndConfigured: true}}, nil + } + + err = svc.VerifyMDMMicrosoftConfigured(ctx) + require.NoError(t, err) + require.True(t, ds.AppConfigFuncInvoked) + ds.AppConfigFuncInvoked = false + require.False(t, authzCtx.Checked()) +} diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go new file mode 100644 index 000000000..33147dc6a --- /dev/null +++ b/server/service/microsoft_mdm.go @@ -0,0 +1,631 @@ +package service + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/logging" + "github.com/fleetdm/fleet/v4/server/fleet" + + mdm_types "github.com/fleetdm/fleet/v4/server/fleet" + mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" + "github.com/google/uuid" +) + +////////////////////////////////////////////////////////////////////////////////////////////////////////// +// MS-MDE2 XML types used by the SOAP protocol +// MS-MDE2 is a client-to-server protocol that consists of a SOAP-based Web service. +// SOAP is a lightweight and XML based protocol that consists of three parts: +// - An envelope that defines a framework for describing what is in a message and how to process it +// - A set of encoding rules for expressing instances of application-defined datatypes +// - And a convention for representing remote procedure calls and responses. + +// SoapResponse is the Soap Envelope Response type for MS-MDE2 responses from the server +// This envelope XML message is composed by a mandatory SOAP envelope, a SOAP header, and a SOAP body +type SoapResponse struct { + XMLName xml.Name `xml:"s:Envelope"` + XmlNSS string `xml:"xmlns:s,attr"` + XmlNSA string `xml:"xmlns:a,attr"` + XmlNSU *string `xml:"xmlns:u,attr,omitempty"` + Header mdm_types.ResponseHeader `xml:"s:Header"` + Body mdm_types.BodyResponse `xml:"s:Body"` +} + +// error returns soap fault error if present +func (msg SoapResponse) error() error { + // placeholder to log SoapFault error message if needed + // msg.Body.SoapFault != nil + + return nil +} + +// hijackRender writes the response header and the RAW XML output +func (msg SoapResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + xmlRes, err := xml.MarshalIndent(msg, "", "\t") + if err != nil { + logging.WithExtras(ctx, "microsoft MDM SoapResponse", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + xmlRes = append(xmlRes, '\n') + + w.Header().Set("Content-Type", mdm.SoapContentType) + w.Header().Set("Content-Length", strconv.Itoa(len(xmlRes))) + w.WriteHeader(http.StatusOK) + if n, err := w.Write(xmlRes); err != nil { + logging.WithExtras(ctx, "err", err, "written", n) + } +} + +// getMessageID returns the message ID from the header +func (req *SoapRequest) getMessageID() string { + return req.Header.MessageID +} + +// isValidHeader checks for required fields in the header +func (req *SoapRequest) isValidHeader() error { + // Check for required fields + + if len(req.XmlNSS) == 0 { + return errors.New("invalid SOAP header: XmlNSS") + } + + if len(req.XmlNSA) == 0 { + return errors.New("invalid SOAP header: XmlNSA") + } + + if len(req.Header.MessageID) == 0 { + return errors.New("invalid SOAP header: Header.MessageID") + } + + if len(req.Header.Action.Content) == 0 { + return errors.New("invalid SOAP header: Header.Action") + } + + if len(req.Header.ReplyTo.Address) == 0 { + return errors.New("invalid SOAP header: Header.ReplyTo") + } + + if len(req.Header.To.Content) == 0 { + return errors.New("invalid SOAP header: Header.To") + } + + return nil +} + +// isValidBody checks for the presence of only one message +func (req *SoapRequest) isValidBody() error { + nonNilCount := 0 + + if req.Body.Discover != nil { + nonNilCount++ + } + if req.Body.GetPolicies != nil { + nonNilCount++ + } + if req.Body.RequestSecurityToken != nil { + nonNilCount++ + } + + if nonNilCount != 1 { + return errors.New("invalid SOAP body: Multiple messages or no message") + } + + return nil +} + +// IsValidDiscoveryMsg checks for required fields in the Discover message +func (req *SoapRequest) IsValidDiscoveryMsg() error { + if err := req.isValidHeader(); err != nil { + return fmt.Errorf("invalid discover message: %s", err) + } + + if err := req.isValidBody(); err != nil { + return fmt.Errorf("invalid discover message: %s", err) + } + + if req.Body.Discover == nil { + return errors.New("invalid discover message: Discover message not present") + } + + if len(req.Body.Discover.XmlNS) == 0 { + return errors.New("invalid discover message: XmlNS") + } + + // TODO: add check for valid email address + if len(req.Body.Discover.Request.EmailAddress) == 0 { + return errors.New("invalid discover message: Request.EmailAddress") + } + + // Ensure that only valid versions are supported + if req.Body.Discover.Request.RequestVersion != mdm.MinEnrollmentVersion && + req.Body.Discover.Request.RequestVersion != mdm.MaxEnrollmentVersion { + return errors.New("invalid discover message: Request.RequestVersion") + } + + // Traverse the AuthPolicies slice and check for valid values + isInvalidAuth := true + for _, authPolicy := range req.Body.Discover.Request.AuthPolicies.AuthPolicy { + if authPolicy == mdm.AuthOnPremise { + isInvalidAuth = false + break + } + } + + if isInvalidAuth { + return errors.New("invalid discover message: Request.AuthPolicies") + } + + return nil +} + +// IsValidGetPolicyMsg checks for required fields in the GetPolicies message +func (req *SoapRequest) IsValidGetPolicyMsg() error { + if err := req.isValidHeader(); err != nil { + return fmt.Errorf("invalid getpolicies message: %s", err) + } + + if err := req.isValidBody(); err != nil { + return fmt.Errorf("invalid getpolicies message: %s", err) + } + + if req.Body.GetPolicies == nil { + return errors.New("invalid getpolicies message: GetPolicies message not present") + } + + if len(req.Body.GetPolicies.XmlNS) == 0 { + return errors.New("invalid getpolicies message: XmlNS") + } + + return nil +} + +// IsValidRequestSecurityTokenMsg checks for required fields in the RequestSecurityToken message +func (req *SoapRequest) IsValidRequestSecurityTokenMsg() error { + if err := req.isValidHeader(); err != nil { + return fmt.Errorf("invalid requestsecuritytoken message: %s", err) + } + + if err := req.isValidBody(); err != nil { + return fmt.Errorf("invalid requestsecuritytoken message: %s", err) + } + + if req.Body.RequestSecurityToken == nil { + return errors.New("invalid requestsecuritytoken message: RequestSecurityToken message not present") + } + + if len(req.Body.RequestSecurityToken.TokenType) == 0 { + return errors.New("invalid requestsecuritytoken message: TokenType") + } + + if len(req.Body.RequestSecurityToken.RequestType) == 0 { + return errors.New("invalid requestsecuritytoken message: RequestType") + } + + if len(req.Body.RequestSecurityToken.BinarySecurityToken.ValueType) == 0 { + return errors.New("invalid requestsecuritytoken message: BinarySecurityToken.ValueType") + } + + if len(req.Body.RequestSecurityToken.BinarySecurityToken.EncodingType) == 0 { + return errors.New("invalid requestsecuritytoken message: BinarySecurityToken.EncodingType") + } + + if len(req.Body.RequestSecurityToken.BinarySecurityToken.Content) == 0 { + return errors.New("invalid requestsecuritytoken message: BinarySecurityToken.Content") + } + + return nil +} + +// SoapRequest is the Soap Envelope Request type for MS-MDE2 responses to the server +// This envelope XML message is composed by a mandatory SOAP envelope, a SOAP header, and a SOAP body +type SoapRequest struct { + XMLName xml.Name `xml:"Envelope"` + XmlNSS string `xml:"s,attr"` + XmlNSA string `xml:"a,attr"` + XmlNSU *string `xml:"u,attr,omitempty"` + XmlNSWsse *string `xml:"wsse,attr,omitempty"` + XmlNSWST *string `xml:"wst,attr,omitempty"` + XmlNSAC *string `xml:"ac,attr,omitempty"` + Header mdm_types.RequestHeader `xml:"Header"` + Body mdm_types.BodyRequest `xml:"Body"` +} + +// getUtcTime returns the current timestamp plus the specified number of minutes, +// formatted as "2006-01-02T15:04:05.000Z". +func getUtcTime(minutes int) string { + // Get the current time and then add the specified number of minutes + now := time.Now() + future := now.Add(time.Duration(minutes) * time.Minute) + + // Format and return the future time as a string + return future.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// NewDiscoverResponse creates a new DiscoverResponse struct based on the auth policy, policy url, and enrollment url +func NewDiscoverResponse(authPolicy string, policyUrl string, enrollmentUrl string) (mdm_types.DiscoverResponse, error) { + if (len(authPolicy) == 0) || (len(policyUrl) == 0) || (len(enrollmentUrl) == 0) { + return mdm_types.DiscoverResponse{}, errors.New("invalid parameters") + } + + return mdm_types.DiscoverResponse{ + XmlNS: mdm.DiscoverNS, + DiscoverResult: mdm_types.DiscoverResult{ + AuthPolicy: authPolicy, + EnrollmentVersion: mdm.MinEnrollmentVersion, + EnrollmentPolicyServiceUrl: policyUrl, + EnrollmentServiceUrl: enrollmentUrl, + }, + }, nil +} + +// NewGetPoliciesResponse creates a new GetPoliciesResponse struct based on the minimal key length, certificate validity period, and renewal period +func NewGetPoliciesResponse(minimalKeyLength string, certificateValidityPeriodSeconds string, renewalPeriodSeconds string) (mdm_types.GetPoliciesResponse, error) { + if (len(minimalKeyLength) == 0) || (len(certificateValidityPeriodSeconds) == 0) || (len(renewalPeriodSeconds) == 0) { + return mdm_types.GetPoliciesResponse{}, errors.New("invalid parameters") + } + + return mdm_types.GetPoliciesResponse{ + XmlNS: mdm.PolicyNS, + Response: mdm_types.Response{ + PolicyFriendlyName: mdm_types.ContentAttr{ + Xsi: mdm.DefaultStateXSI, + XmlNS: mdm.EnrollXSI, + }, + NextUpdateHours: mdm_types.ContentAttr{ + Xsi: mdm.DefaultStateXSI, + XmlNS: mdm.EnrollXSI, + }, + PoliciesNotChanged: mdm_types.ContentAttr{ + Xsi: mdm.DefaultStateXSI, + XmlNS: mdm.EnrollXSI, + }, + Policies: mdm_types.Policies{ + Policy: mdm_types.GPPolicy{ + PolicyOIDReference: "0", + CAs: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + Attributes: mdm_types.Attributes{ + CommonName: "FleetDMAttributes", + PolicySchema: "3", + HashAlgorithmOIDReference: "0", + Revision: mdm_types.Revision{ + MajorRevision: "101", + MinorRevision: "0", + }, + CertificateValidity: mdm_types.CertificateValidity{ + ValidityPeriodSeconds: certificateValidityPeriodSeconds, + RenewalPeriodSeconds: renewalPeriodSeconds, + }, + Permission: mdm_types.Permission{ + Enroll: "true", + AutoEnroll: "false", + }, + SupersededPolicies: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + PrivateKeyFlags: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + SubjectNameFlags: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + EnrollmentFlags: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + GeneralFlags: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + RARequirements: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + KeyArchivalAttributes: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + Extensions: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + PrivateKeyAttributes: mdm_types.PrivateKeyAttributes{ + MinimalKeyLength: minimalKeyLength, + KeySpec: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + KeyUsageProperty: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + Permissions: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + AlgorithmOIDReference: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + CryptoProviders: mdm_types.GenericAttr{ + Xsi: mdm.DefaultStateXSI, + }, + }, + }, + }, + }, + }, + OIDs: mdm_types.OIDs{ + // SHA1WithRSA encryption OID + // https://oidref.com/1.3.14.3.2.29 + OID: mdm_types.OID{ + Value: "1.3.14.3.2.29", + Group: "1", + OIDReferenceID: "0", + DefaultName: "szOID_NIST_sha256", + }, + }, + }, nil +} + +// NewRequestSecurityTokenResponseCollection creates a new RequestSecurityTokenResponseCollection struct based on the provisioned token +func NewRequestSecurityTokenResponseCollection(provisionedToken string) (mdm_types.RequestSecurityTokenResponseCollection, error) { + if len(provisionedToken) == 0 { + return mdm_types.RequestSecurityTokenResponseCollection{}, errors.New("invalid parameters") + } + + enrollSecExtVal := mdm.EnrollSecExt + return mdm_types.RequestSecurityTokenResponseCollection{ + XmlNS: mdm.EnrollWSTrust, + RequestSecurityTokenResponse: mdm_types.RequestSecurityTokenResponse{ + TokenType: mdm.EnrollTType, + DispositionMessage: mdm_types.SecAttr{ + Content: "", + XmlNS: mdm.EnrollReq, + }, + RequestID: mdm_types.SecAttr{ + Content: "0", + XmlNS: mdm.EnrollReq, + }, + RequestedSecurityToken: mdm_types.RequestedSecurityToken{ + BinarySecurityToken: mdm_types.BinarySecurityToken{ + Content: provisionedToken, + XmlNS: &enrollSecExtVal, + ValueType: mdm.EnrollPDoc, + EncodingType: mdm.EnrollEncode, + }, + }, + }, + }, nil +} + +// NewSoapFault creates a new SoapFault struct based on the error type, original message type, and error message +func NewSoapFault(errorType string, origMessage int, errorMessage error) mdm_types.SoapFault { + return mdm_types.SoapFault{ + OriginalMessageType: origMessage, + Code: mdm_types.Code{ + Value: mdm.SoapFaultRecv, + Subcode: mdm_types.Subcode{ + Value: errorType, + }, + }, + Reason: mdm_types.Reason{ + Text: mdm_types.ReasonText{ + Content: errorMessage.Error(), + Lang: mdm.SoapFaultLocale, + }, + }, + } +} + +// getSoapResponseFault Returns a SoapResponse with a SoapFault on its body +func getSoapResponseFault(relatesTo string, soapFault *mdm_types.SoapFault) errorer { + if len(relatesTo) == 0 { + relatesTo = "invalid_message_id" + } + + soapResponse, _ := NewSoapResponse(soapFault, relatesTo) + return soapResponse +} + +// NewSoapResponse creates a new SoapRequest struct based on the message type and the message content +func NewSoapResponse(payload interface{}, relatesTo string) (SoapResponse, error) { + // Sanity check + if len(relatesTo) == 0 { + return SoapResponse{}, errors.New("relatesTo is invalid") + } + + // Useful constants + // Some of these are string urls to be assigned to pointers - they need to have a type and cannot be const literals + var ( + urlNSS = mdm.EnrollNSS + urlNSA = mdm.EnrollNSA + urlXSI = mdm.EnrollXSI + urlXSD = mdm.EnrollXSD + urlXSU = mdm.EnrollXSU + urlDiag = mdm.ActionNsDiag + urlDiscovery = mdm.ActionNsDiscovery + urlPolicy = mdm.ActionNsPolicy + urlEnroll = mdm.ActionNsEnroll + urlSecExt = mdm.EnrollSecExt + MUValue = "1" + timestampID = "_0" + secWindowStartTimeMin = -5 + secWindowEndTimeMin = 5 + ) + + // string pointers - they need to be pointers to not be marshalled into the XML when nil + var ( + headerXsu *string + action string + activityID *mdm_types.ActivityId + security *mdm_types.WsSecurity + ) + + // Build the response body + var body mdm_types.BodyResponse + + // Set the message specific fields based on the message type + switch msg := payload.(type) { + + case *mdm_types.DiscoverResponse: + action = urlDiscovery + uuid := uuid.New().String() + activityID = &mdm_types.ActivityId{ + Content: uuid, + CorrelationId: uuid, + XmlNS: urlDiag, + } + body.DiscoverResponse = msg + + case *mdm_types.GetPoliciesResponse: + action = urlPolicy + headerXsu = &urlXSU + body.Xsi = &urlXSI + body.Xsd = &urlXSD + body.GetPoliciesResponse = msg + + case *mdm_types.RequestSecurityTokenResponseCollection: + action = urlEnroll + headerXsu = &urlXSU + security = &mdm_types.WsSecurity{ + MustUnderstand: MUValue, + XmlNS: urlSecExt, + Timestamp: mdm_types.Timestamp{ + ID: timestampID, + Created: getUtcTime(secWindowStartTimeMin), // minutes ago + Expires: getUtcTime(secWindowEndTimeMin), // minutes from now + }, + } + body.RequestSecurityTokenResponseCollection = msg + + // Setting the target action + case *mdm_types.SoapFault: + if msg.OriginalMessageType == mdm_types.MDEDiscovery { + action = urlDiscovery + } else if msg.OriginalMessageType == mdm_types.MDEPolicy { + action = urlPolicy + } else if msg.OriginalMessageType == mdm_types.MDEEnrollment { + action = urlEnroll + } else { + action = urlDiag + } + uuid := uuid.New().String() + activityID = &mdm_types.ActivityId{ + Content: uuid, + CorrelationId: uuid, + XmlNS: urlDiag, + } + body.SoapFault = msg + + default: + return SoapResponse{}, errors.New("mdm response message not supported") + } + + // Return the SoapRequest type with the appropriate fields set + return SoapResponse{ + XmlNSS: urlNSS, + XmlNSA: urlNSA, + XmlNSU: headerXsu, + Header: mdm_types.ResponseHeader{ + Action: mdm_types.Action{ + Content: action, + MustUnderstand: MUValue, + }, + RelatesTo: relatesTo, + ActivityId: activityID, + Security: security, + }, + Body: body, + }, nil +} + +// MDM SOAP request decoder +func (req *SoapRequest) DecodeBody(ctx context.Context, r io.Reader) error { + // Reading the request bytes + reqBytes, err := io.ReadAll(r) + if err != nil { + return ctxerr.Wrap(ctx, err, "reading soap mdm request") + } + + // Unmarshal the XML data from the request into the SoapRequest struct + err = xml.Unmarshal(reqBytes, &req) + if err != nil { + return ctxerr.Wrap(ctx, err, "unmarshalling soap mdm request") + } + + return nil +} + +// mdmMicrosoftDiscoveryEndpoint is the response struct for the GetDiscovery endpoint +func mdmMicrosoftDiscoveryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*SoapRequest) + + // Checking first if Discovery message is valid and returning error if this is not the case + if err := req.IsValidDiscoveryMsg(); err != nil { + soapFault := svc.GetAuthorizedSoapFault(ctx, mdm.SoapErrorMessageFormat, mdm_types.MDEDiscovery, err) + return getSoapResponseFault(req.getMessageID(), soapFault), nil + } + + // Getting the DiscoveryResponse message + discoveryMessage, err := svc.GetMDMMicrosoftDiscoveryResponse(ctx) + if err != nil { + soapFault := svc.GetAuthorizedSoapFault(ctx, mdm.SoapErrorMessageFormat, mdm_types.MDEDiscovery, err) + return getSoapResponseFault(req.getMessageID(), soapFault), nil + } + + // Embedding the DiscoveryResponse message inside of a SoapResponse + response, err := NewSoapResponse(discoveryMessage, req.getMessageID()) + if err != nil { + soapFault := svc.GetAuthorizedSoapFault(ctx, mdm.SoapErrorMessageFormat, mdm_types.MDEDiscovery, err) + return getSoapResponseFault(req.getMessageID(), soapFault), nil + } + + return response, nil +} + +// GetMDMMicrosoftDiscoveryResponse returns a valid DiscoveryResponse message +func (svc *Service) GetMDMMicrosoftDiscoveryResponse(ctx context.Context) (*fleet.DiscoverResponse, error) { + // skipauth: This endpoint does not use authentication + svc.authz.SkipAuthorization(ctx) + + // Getting the app config + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + // Getting the DiscoveryResponse message content ready + + urlDiscoveryEndpoint, err := mdm.ResolveMicrosoftMDMDiscovery(appCfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + urlPolicyEndpoint, err := mdm.ResolveMicrosoftMDMPolicy(appCfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + urlEnrollEndpoint, err := mdm.ResolveMicrosoftMDMEnroll(appCfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + discoveryMsg, err := NewDiscoverResponse(urlDiscoveryEndpoint, urlPolicyEndpoint, urlEnrollEndpoint) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + return &discoveryMsg, nil +} + +// GetAuthorizedSoapFault authorize the request so SoapFault message can be returned +func (svc *Service) GetAuthorizedSoapFault(ctx context.Context, eType string, origMsg int, errorMsg error) *fleet.SoapFault { + svc.authz.SkipAuthorization(ctx) + + soapFault := NewSoapFault(eType, origMsg, errorMsg) + + return &soapFault +} diff --git a/server/service/microsoft_mdm_test.go b/server/service/microsoft_mdm_test.go new file mode 100644 index 000000000..358f34462 --- /dev/null +++ b/server/service/microsoft_mdm_test.go @@ -0,0 +1,195 @@ +package service + +import ( + "encoding/xml" + "errors" + "fmt" + "testing" + + mdm_types "github.com/fleetdm/fleet/v4/server/fleet" + mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" + "github.com/stretchr/testify/require" +) + +// NewSoapRequest takes a SOAP request in the form of a byte slice and tries to unmarshal it into a SoapRequest struct. +func NewSoapRequest(request []byte) (SoapRequest, error) { + // Sanity check on input + if len(request) == 0 { + return SoapRequest{}, errors.New("soap request is invalid") + } + + // Unmarshal the XML data from the request into the SoapRequest struct + var req SoapRequest + err := xml.Unmarshal(request, &req) + if err != nil { + return req, fmt.Errorf("there was a problem unmarshalling soap request: %v", err) + } + + // If there was no error, return the SoapRequest and a nil error + return req, nil +} + +func TestValidSoapResponse(t *testing.T) { + relatesTo := "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749" + soapFaultMsg := NewSoapFault(mdm.SoapErrorAuthentication, mdm_types.MDEDiscovery, errors.New("test")) + sres, err := NewSoapResponse(&soapFaultMsg, relatesTo) + require.NoError(t, err) + outXML, err := xml.MarshalIndent(sres, "", " ") + require.NoError(t, err) + require.NotEmpty(t, outXML) + require.Contains(t, string(outXML), fmt.Sprintf("%s", relatesTo)) +} + +func TestInvalidSoapResponse(t *testing.T) { + relatesTo := "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749" + _, err := NewSoapResponse(relatesTo, relatesTo) + require.Error(t, err) +} + +func TestFaultMessageSoapResponse(t *testing.T) { + targetErrorString := "invalid input request" + soapFaultMsg := NewSoapFault(mdm.SoapErrorAuthentication, mdm_types.MDEDiscovery, errors.New(targetErrorString)) + sres, err := NewSoapResponse(&soapFaultMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749") + require.NoError(t, err) + outXML, err := xml.MarshalIndent(sres, "", " ") + require.NoError(t, err) + require.NotEmpty(t, outXML) + require.Contains(t, string(outXML), fmt.Sprintf("%s", targetErrorString)) +} + +// func NewRequestSecurityTokenResponseCollection(provisionedToken string) (mdm_types.RequestSecurityTokenResponseCollection, error) { +func TestRequestSecurityTokenResponseCollectionSoapResponse(t *testing.T) { + provisionedToken := "provisionedToken" + reqSecTokenCollectionMsg, err := NewRequestSecurityTokenResponseCollection(provisionedToken) + require.NoError(t, err) + sres, err := NewSoapResponse(&reqSecTokenCollectionMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749") + require.NoError(t, err) + outXML, err := xml.MarshalIndent(sres, "", " ") + require.NoError(t, err) + require.NotEmpty(t, outXML) + require.Contains(t, string(outXML), fmt.Sprintf("base64binary\">%s", provisionedToken)) +} + +func TestGetPoliciesResponseSoapResponse(t *testing.T) { + minKey := "2048" + getPoliciesMsg, err := NewGetPoliciesResponse(minKey, "10", "20") + require.NoError(t, err) + sres, err := NewSoapResponse(&getPoliciesMsg, "urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749") + require.NoError(t, err) + outXML, err := xml.MarshalIndent(sres, "", " ") + require.NoError(t, err) + require.NotEmpty(t, outXML) + require.Contains(t, string(outXML), fmt.Sprintf("%s", minKey)) +} + +func TestValidSoapRequestWithDiscoverMsg(t *testing.T) { + requestBytes := []byte(` + + + http://schemas.microsoft.com/windows/management/2012/01/enrollment/IDiscoveryService/Discover + urn:uuid:748132ec-a575-4329-b01b-6171a9cf8478 + + http://www.w3.org/2005/08/addressing/anonymous + + https://mdmwindows.com:443/EnrollmentServer/Discovery.svc + + + + + demo@mdmwindows.com + 5.0 + CIMClient_Windows + 6.2.9200.2965 + 48 + + OnPremise + Federated + + + + + + `) + + req, err := NewSoapRequest(requestBytes) + require.NoError(t, err) + err = req.IsValidDiscoveryMsg() + require.NoError(t, err) +} + +func TestInvalidSoapRequestWithDiscoverMsg(t *testing.T) { + requestBytes := []byte(` + + + http://schemas.microsoft.com/windows/pki/2009/01/enrollment/RST/wstep + urn:uuid:0d5a1441-5891-453b-becf-a2e5f6ea3749 + + http://www.w3.org/2005/08/addressing/anonymous + + https://mdmwindows.com/EnrollmentServer/Enrollment.svc + + aGVsbG93b3JsZA== + + + + + http://schemas.microsoft.com/5.0.0.0/ConfigurationManager/Enrollment/DeviceEnrollmentToken + http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue + MIICzjCCAboCAQAwSzFJMEcGA1UEAxNAMkI5QjUyQUMtREYzOC00MTYxLTgxNDItRjRCMUUwIURCMjU3QzNBMDg3NzhGNEZCNjFFMjc0OTA2NkMxRjI3ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKogsEpbKL8fuXpTNAE5RTZim8JO5CCpxj3z+SuWabs/s9Zse6RziKr12R4BXPiYE1zb8god4kXxet8x3ilGqAOoXKkdFTdNkdVa23PEMrIZSX5MuQ7mwGtctayARxmDvsWRF/icxJbqSO+bYIKvuifesOCHW2cJ1K+JSKijTMik1N8NFbLi5fg1J+xImT9dW1z2fLhQ7SNEMLosUPHsbU9WKoDBfnPsLHzmhM2IMw+5dICZRoxHZalh70FefBk0XoT8b6w4TIvc8572TyPvvdwhc5o/dvyR3nAwTmJpjBs1YhJfSdP+EBN1IC2T/i/mLNUuzUSC2OwiHPbZ6MMr/hUCAwEAAaBCMEAGCSqGSIb3DQEJDjEzMDEwLwYKKwYBBAGCN0IBAAQhREIyNTdDM0EwODc3OEY0RkI2MUUyNzQ5MDY2QzFGMjcAMAkGBSsOAwIdBQADggEBACQtxyy74sCQjZglwdh/Ggs6ofMvnWLMq9A9rGZyxAni66XqDUoOg5PzRtSt+Gv5vdLQyjsBYVzo42W2HCXLD2sErXWwh/w0k4H7vcRKgEqv6VYzpZ/YRVaewLYPcqo4g9NoXnbW345OPLwT3wFvVR5v7HnD8LB2wHcnMu0fAQORgafCRWJL1lgw8VZRaGw9BwQXCF/OrBNJP1ivgqtRdbSoH9TD4zivlFFa+8VDz76y2mpfo0NbbD+P0mh4r0FOJan3X9bLswOLFD6oTiyXHgcVSzLN0bQ6aQo0qKp3yFZYc8W4SgGdEl07IqNquKqJ/1fvmWxnXEbl3jXwb1efhbM= + + + false + + + BF2D12A95AE42E47D58465E9A71336CAF33FCCAD3088F140F4D50B371FB2256F + + + en-US + + + true + + + 48 + + + DESKTOP-0C89RC0 + + + 00-0C-29-7B-4E-4C + + + 00-0C-29-7B-4E-56 + + + DB257C3A08778F4FB61E2749066C1F27 + + + Full + + + CIMClient_Windows + + + 10.0.19045.2965 + + + 10.0.19045.2965 + + + false + + + 5.0 + + + + + + `) + + req, err := NewSoapRequest(requestBytes) + require.NoError(t, err) + err = req.IsValidDiscoveryMsg() + require.Error(t, err) +} diff --git a/server/service/middleware/mdmconfigured/mdmconfigured.go b/server/service/middleware/mdmconfigured/mdmconfigured.go index cc8cac41c..c26ea31ad 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured.go @@ -13,11 +13,11 @@ type Middleware struct { svc fleet.Service } -func NewAppleMiddleware(svc fleet.Service) *Middleware { +func NewMDMConfigMiddleware(svc fleet.Service) *Middleware { return &Middleware{svc: svc} } -func (m *Middleware) Verify() endpoint.Middleware { +func (m *Middleware) VerifyAppleMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { if err := m.svc.VerifyMDMAppleConfigured(ctx); err != nil { @@ -28,3 +28,15 @@ func (m *Middleware) Verify() endpoint.Middleware { } } } + +func (m *Middleware) VerifyMicrosoftMDM() endpoint.Middleware { + return func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req interface{}) (interface{}, error) { + if err := m.svc.VerifyMDMMicrosoftConfigured(ctx); err != nil { + return nil, err + } + + return next(ctx, req) + } + } +} diff --git a/server/service/middleware/mdmconfigured/mdmconfigured_test.go b/server/service/middleware/mdmconfigured/mdmconfigured_test.go index 8eb8fd661..74a088312 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured_test.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured_test.go @@ -14,7 +14,8 @@ type mockService struct { mock.Mock fleet.Service - mdmConfigured atomic.Bool + mdmConfigured atomic.Bool + msMdmConfigured atomic.Bool } func (m *mockService) VerifyMDMAppleConfigured(ctx context.Context) error { @@ -24,10 +25,17 @@ func (m *mockService) VerifyMDMAppleConfigured(ctx context.Context) error { return nil } +func (m *mockService) VerifyMDMMicrosoftConfigured(ctx context.Context) error { + if !m.msMdmConfigured.Load() { + return fleet.ErrMDMNotConfigured + } + return nil +} + func TestMDMConfigured(t *testing.T) { svc := mockService{} svc.mdmConfigured.Store(true) - mw := NewAppleMiddleware(&svc) + mw := NewMDMConfigMiddleware(&svc) nextCalled := false next := func(ctx context.Context, req interface{}) (interface{}, error) { @@ -35,7 +43,7 @@ func TestMDMConfigured(t *testing.T) { return struct{}{}, nil } - f := mw.Verify()(next) + f := mw.VerifyAppleMDM()(next) _, err := f(context.Background(), struct{}{}) require.NoError(t, err) require.True(t, nextCalled) @@ -44,7 +52,7 @@ func TestMDMConfigured(t *testing.T) { func TestMDMNotConfigured(t *testing.T) { svc := mockService{} svc.mdmConfigured.Store(false) - mw := NewAppleMiddleware(&svc) + mw := NewMDMConfigMiddleware(&svc) nextCalled := false next := func(ctx context.Context, req interface{}) (interface{}, error) { @@ -52,7 +60,41 @@ func TestMDMNotConfigured(t *testing.T) { return struct{}{}, nil } - f := mw.Verify()(next) + f := mw.VerifyAppleMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) + require.False(t, nextCalled) +} + +func TestMicrosoftMDMConfigured(t *testing.T) { + svc := mockService{} + svc.msMdmConfigured.Store(true) + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyMicrosoftMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.NoError(t, err) + require.True(t, nextCalled) +} + +func TestMicrosoftMDMNotConfigured(t *testing.T) { + svc := mockService{} + svc.msMdmConfigured.Store(false) + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req interface{}) (interface{}, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyMicrosoftMDM()(next) _, err := f(context.Background(), struct{}{}) require.ErrorIs(t, err, fleet.ErrMDMNotConfigured) require.False(t, nextCalled) diff --git a/server/service/orbit.go b/server/service/orbit.go index b18018e0c..e0616ecd6 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -11,8 +11,9 @@ import ( hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" - windows_mdm "github.com/fleetdm/fleet/v4/server/mdm/windows" "github.com/go-kit/kit/log/level" + + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" ) type setOrbitNodeKeyer interface { @@ -205,15 +206,15 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } - // set the host's orbit notifications for Windows MDM - if config.MDM.WindowsEnabledAndConfigured { - if host.IsElegibleForWindowsMDMEnrollment() { - discoURL, err := windows_mdm.ResolveWindowsMDMDiscovery(config.ServerSettings.ServerURL) + // set the host's orbit notifications for Microsoft MDM + if config.MDM.MicrosoftEnabledAndConfigured { + if host.IsElegibleForMicrosoftMDMEnrollment() { + discoURL, err := microsoft_mdm.ResolveMicrosoftMDMDiscovery(config.ServerSettings.ServerURL) if err != nil { return fleet.OrbitConfig{Notifications: notifs}, err } - notifs.WindowsMDMDiscoveryEndpoint = discoURL - notifs.NeedsProgrammaticWindowsMDMEnrollment = true + notifs.MicrosoftMDMDiscoveryEndpoint = discoURL + notifs.NeedsProgrammaticMicrosoftMDMEnrollment = true } } diff --git a/tools/mdm/windows/poc-mdm-server/README.md b/tools/mdm/windows/poc-mdm-server/README.md index 9ac47cba0..acb5d3a2d 100644 --- a/tools/mdm/windows/poc-mdm-server/README.md +++ b/tools/mdm/windows/poc-mdm-server/README.md @@ -1,5 +1,5 @@ -# Windows MDM Server Demo +# Microsoft MDM Server Demo This project is a working and minimal implementation of the Windows device enrollment and management protocols. It was based on an initial implementation of the MS-MDE enrollment protocols [here](https://github.com/oscartbeaumont/windows_mdm). diff --git a/tools/mdm/windows/poc-mdm-server/main.go b/tools/mdm/windows/poc-mdm-server/main.go index ce15abb84..608d0378b 100644 --- a/tools/mdm/windows/poc-mdm-server/main.go +++ b/tools/mdm/windows/poc-mdm-server/main.go @@ -16,15 +16,17 @@ import ( // Code forked from https://github.com/oscartbeaumont/windows_mdm // Global config, populated via Command line flags -var domain string -var deepLinkUserEmail string -var authPolicy string -var profileDir string -var staticDir string -var verbose bool +var ( + domain string + deepLinkUserEmail string + authPolicy string + profileDir string + staticDir string + verbose bool +) func main() { - fmt.Println("Starting Windows MDM Demo Server") + fmt.Println("Starting Microsoft MDM Demo Server") // Parse CMD flags. This populates the varibles defined above flag.StringVar(&domain, "domain", "mdmwindows.com", "Your servers primary domain") @@ -63,20 +65,20 @@ func main() { // Create HTTP request router r := mux.NewRouter() - //MS-MDE and MS-MDM endpoints + // MS-MDE and MS-MDM endpoints r.Path("/EnrollmentServer/Discovery.svc").Methods("GET", "POST").HandlerFunc(DiscoveryHandler) r.Path("/EnrollmentServer/Policy.svc").Methods("POST").HandlerFunc(PolicyHandler) r.Path("/EnrollmentServer/Enrollment.svc").Methods("POST").HandlerFunc(EnrollHandler) r.Path("/ManagementServer/MDM.svc").Methods("POST").HandlerFunc(ManageHandler) - //Static root endpoint + // Static root endpoint r.Path("/").Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=UTF-8") - w.Write([]byte(`

FleetDM Windows MDM Demo Server

.
`)) + w.Write([]byte(`

FleetDM Microsoft MDM Demo Server

.
`)) w.Write([]byte(`
`)) }) - //Static file serve + // Static file serve fileServer := http.FileServer(http.Dir(staticDir)) r.PathPrefix("/").Handler(http.StripPrefix("/static", fileServer)) @@ -111,7 +113,6 @@ func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, body []byte, err error) { // global HTTP handler to log input and output https traffic func globalHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if verbose { // grabbing Input Header and Body reqHeader, err := httputil.DumpRequest(r, false) diff --git a/tools/mdm/windows/poc-mdm-server/patch/patch.go b/tools/mdm/windows/poc-mdm-server/patch/patch.go index a4a5258ae..9dbbdbcfb 100644 --- a/tools/mdm/windows/poc-mdm-server/patch/patch.go +++ b/tools/mdm/windows/poc-mdm-server/patch/patch.go @@ -38,9 +38,9 @@ func main() { if scanner.Text() == " b == '?' ||" { scanner.Scan() - if scanner.Text() != " b == '!' || // Windows MDM Certificate Parsing Patch" { - out.Write([]byte(" b == '!' || // Windows MDM Certificate Parsing Patch\n")) - out.Write([]byte(" b == 0 || // Windows MDM Certificate Parsing Patch\n")) + if scanner.Text() != " b == '!' || // Microsoft MDM Certificate Parsing Patch" { + out.Write([]byte(" b == '!' || // Microsoft MDM Certificate Parsing Patch\n")) + out.Write([]byte(" b == 0 || // Microsoft MDM Certificate Parsing Patch\n")) } out.Write(scanner.Bytes())