From 6927bb6a8fb7fdbaa5d541bb094b0a94498e9754 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 12 Feb 2026 19:27:35 -0500 Subject: [PATCH] 39272 Check entra tenant ID (#39780) **Related issue:** Resolves #39272 Changes file already added on another subtask # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually --- pkg/mdm/mdmtest/windows.go | 9 ++- server/mdm/microsoft/wstep.go | 37 +++++++++ .../service/integration_mdm_lifecycle_test.go | 7 +- server/service/integration_mdm_test.go | 79 +++++++++++++++---- server/service/microsoft_mdm.go | 46 ++++++++++- 5 files changed, 159 insertions(+), 19 deletions(-) diff --git a/pkg/mdm/mdmtest/windows.go b/pkg/mdm/mdmtest/windows.go index 96ba3ea9e9..f9895d8a4f 100644 --- a/pkg/mdm/mdmtest/windows.go +++ b/pkg/mdm/mdmtest/windows.go @@ -50,6 +50,8 @@ type TestWindowsMDMClient struct { jwtSigningKey *rsa.PrivateKey // jwtSigningKeyID is the ID to report in the header for the signing key jwtSigningKeyID string + // Entra Tenant ID to include int he JWT + entraTenantID string username string password string @@ -77,10 +79,11 @@ func TestWindowsMDMClientNotInOOBE() TestWindowsMDMClientOption { } } -func TestWindowsMDMClientWithSigningKey(signingKey *rsa.PrivateKey, signingKeyID string) TestWindowsMDMClientOption { +func TestWindowsMDMClientWithSigningKeyAndTenantID(signingKey *rsa.PrivateKey, signingKeyID, tenantID string) TestWindowsMDMClientOption { return func(c *TestWindowsMDMClient) { c.jwtSigningKey = signingKey c.jwtSigningKeyID = signingKeyID + c.entraTenantID = tenantID } } @@ -687,9 +690,11 @@ func (c *TestWindowsMDMClient) getToken() (binarySecToken string, tokenValueType case fleet.WindowsMDMAutomaticEnrollmentType: claims := &jwt.MapClaims{ "upn": c.TokenIdentifier, - "tid": "tenant_id", + "tid": c.entraTenantID, "unique_name": "foo_bar", "scp": "mdm_delegation", + "iss": "https://sts.windows.net/" + c.entraTenantID + "/", + "aud": c.fleetServerURL, } if c.jwtSigningKey == nil || c.jwtSigningKeyID == "" { return "", "", errors.New("jwt signing key is not set") diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 0cc9a4f7e8..ee2305cce5 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" "github.com/smallstep/pkcs7" ) @@ -67,6 +68,7 @@ type STSClaims struct { type AzureData struct { UPN string + Audience []string TenantID string UniqueName string SCP string @@ -320,6 +322,40 @@ func GetAzureAuthTokenClaims(ctx context.Context, tokenStr string) (AzureData, e return AzureData{}, ctxerr.New(ctx, "invalid TenantID claim") } + // Validate that tenant ID is a UUID and matches the issuer + _, err = uuid.Parse(tenantIDClaim) + if err != nil { + return AzureData{}, ctxerr.Wrap(ctx, err, "invalid TenantID claim format") + } + issuer, ok := claims["iss"].(string) + if !ok || len(issuer) == 0 { + return AzureData{}, ctxerr.New(ctx, "invalid Issuer claim") + } + + // Depending on exactly how the Azure AD app is configured, the issuer claim + // may vary. Validate that the issuer contains the tenant ID. + issuerMatchesTenant := false + for _, expectedIssuer := range []string{fmt.Sprintf("https://sts.windows.net/%s/", tenantIDClaim), fmt.Sprintf("https://login.microsoftonline.com/%s/", tenantIDClaim)} { + if strings.HasPrefix(issuer, expectedIssuer) { + issuerMatchesTenant = true + break + } + } + if !issuerMatchesTenant { + return AzureData{}, ctxerr.New(ctx, "issuer claim does not match tenant ID") + } + + audience := []string{} + singleAudience, ok := claims["aud"].(string) + if !ok { + multiAudience, ok := claims["aud"].([]string) + if ok { + audience = multiAudience + } + } else { + audience = append(audience, singleAudience) + } + // Get UniqueName claim uniqueNameClaim, ok := claims["unique_name"].(string) if !ok { @@ -337,6 +373,7 @@ func GetAzureAuthTokenClaims(ctx context.Context, tokenStr string) (AzureData, e TenantID: tenantIDClaim, UniqueName: uniqueNameClaim, SCP: azureSCPClaim, + Audience: audience, }, nil } diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 77e8449f02..378602a686 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -458,13 +458,18 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() { t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO") } + tenantID := uuid.New().String() + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + err := s.ds.ApplyEnrollSecrets(context.Background(), nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) host := createOrbitEnrolledHost(t, "windows", "windows_automatic", s.ds) azureMail := "foo.bar.baz@example.com" - device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID)) + device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) device.HardwareID = host.UUID device.DeviceID = host.UUID require.NoError(t, device.Enroll()) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index a826b3d80d..937effa3dc 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1610,23 +1610,23 @@ func enrollWindowsHostInMDMViaOrbit(t *testing.T, host *fleet.Host, ds fleet.Dat } // Simulates a host being orbit enrolled first then an MDM enrollment coming via the settings app -func (s *integrationMDMTestSuite) createWindowsHostThenEnrollMDMViaSettingsApp(fleetServerURL, email string) (*fleet.Host, *mdmtest.TestWindowsMDMClient) { +func (s *integrationMDMTestSuite) createWindowsHostThenEnrollMDMViaSettingsApp(fleetServerURL, email, tenantID string) (*fleet.Host, *mdmtest.TestWindowsMDMClient) { host := createOrbitEnrolledHost(s.T(), "windows", uuid.NewString(), s.ds) - mdmDevice := s.enrollWindowsMDMViaSettingsApp(fleetServerURL, email) + mdmDevice := s.enrollWindowsMDMViaSettingsApp(fleetServerURL, email, tenantID) return host, mdmDevice } // Note that this method only creates the MDM Enrollment but it will still need to be linked to the host record either // via DS methods or by simualting a refetch. -func (s *integrationMDMTestSuite) enrollWindowsMDMViaSettingsApp(fleetServerURL, email string) *mdmtest.TestWindowsMDMClient { - mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientNotInOOBE(), mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID)) +func (s *integrationMDMTestSuite) enrollWindowsMDMViaSettingsApp(fleetServerURL, email, tenantID string) *mdmtest.TestWindowsMDMClient { + mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientNotInOOBE(), mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) err := mdmDevice.Enroll() require.NoError(s.T(), err) return mdmDevice } -func (s *integrationMDMTestSuite) enrollWindowsHostInMDMViaAutopilot(fleetServerURL, email string) *mdmtest.TestWindowsMDMClient { - mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID)) +func (s *integrationMDMTestSuite) enrollWindowsHostInMDMViaAutopilot(fleetServerURL, email, tenantID string) *mdmtest.TestWindowsMDMClient { + mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) err := mdmDevice.Enroll() require.NoError(s.T(), err) return mdmDevice @@ -7944,13 +7944,20 @@ func (s *integrationMDMTestSuite) TestValidGetPoliciesRequestWithDeviceToken() { func (s *integrationMDMTestSuite) TestValidGetPoliciesRequestWithAzureToken() { t := s.T() + tenantID := uuid.New().String() + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + // Preparing the GetPolicies Request message with Azure JWT token // Preparing the SecurityToken Request message with Azure JWT token claims := &jwt.MapClaims{ "upn": "fleetie@example.com", - "tid": "tenant_id", + "tid": tenantID, + "iss": "https://sts.windows.net/" + tenantID + "/", "unique_name": "foo_bar", "scp": "mdm_delegation", + "aud": s.server.URL + microsoft_mdm.MDE2PolicyPath, } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) @@ -8120,12 +8127,19 @@ func (s *integrationMDMTestSuite) TestValidRequestSecurityTokenRequestWithDevice func (s *integrationMDMTestSuite) TestValidRequestSecurityTokenRequestWithAzureToken() { t := s.T() + tenantID := uuid.New().String() + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + // Preparing the SecurityToken Request message with Azure JWT token claims := &jwt.MapClaims{ "upn": "fleetie@example.com", - "tid": "tenant_id", + "tid": tenantID, + "iss": "https://sts.windows.net/" + tenantID + "/", "unique_name": "foo_bar", "scp": "mdm_delegation", + "aud": s.server.URL, } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) @@ -8702,8 +8716,12 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() { err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) + tenantID := uuid.New().String() + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + azureMail := "foo.bar.baz@example.com" - d := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID)) + d := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) require.NoError(t, d.Enroll()) checkinAndAck := func(expectFleetdCmds bool) { @@ -8806,20 +8824,49 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedBadKeys() { _, badKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) + tenantID := uuid.New().String() + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + autopilotUserMail := "swan@example.com" // Bad key - mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(badKey, defaultFakeJWTKeyID)) + mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(badKey, defaultFakeJWTKeyID, tenantID)) err = mdmDevice.Enroll() require.Error(s.T(), err) // Good key but wrong ID - mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, "bad-key-id")) + mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, "bad-key-id", tenantID)) err = mdmDevice.Enroll() require.Error(s.T(), err) // Happy path to ensure the setup is correct - mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID)) + mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) + err = mdmDevice.Enroll() + require.NoError(s.T(), err) +} + +func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedTenantIDs() { + t := s.T() + ctx := context.Background() + + // define a global enroll secret + err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) + require.NoError(t, err) + + tenantID := uuid.New().String() + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + + autopilotUserMail := "swan@example.com" + + // Bad entra tenant ID + mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, uuid.New().String())) + err = mdmDevice.Enroll() + require.Error(s.T(), err) + + // Happy path to ensure the setup is correct + mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID)) err = mdmDevice.Enroll() require.NoError(s.T(), err) } @@ -8832,6 +8879,10 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedEnrollmentAndMapping( err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) + tenantID := uuid.New().String() + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp) + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: "team1_" + t.Name(), Description: "desc team1_" + t.Name(), @@ -8840,11 +8891,11 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedEnrollmentAndMapping( // Enroll another host to ensure the wires don't get crossed somehow autopilotUserMail := "swan@example.com" - autopilotDevice := s.enrollWindowsHostInMDMViaAutopilot(s.server.URL, autopilotUserMail) + autopilotDevice := s.enrollWindowsHostInMDMViaAutopilot(s.server.URL, autopilotUserMail, tenantID) require.NoError(t, autopilotDevice.Enroll()) settingsAppUserMail := "fleetie@example.com" - settingsAppHost, settingsAppDevice := s.createWindowsHostThenEnrollMDMViaSettingsApp(s.server.URL, settingsAppUserMail) + settingsAppHost, settingsAppDevice := s.createWindowsHostThenEnrollMDMViaSettingsApp(s.server.URL, settingsAppUserMail, tenantID) require.NoError(t, settingsAppDevice.Enroll()) // Transfer the host to the team. Ensure it doesn't wind up in "No team" at the end diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index aa1ad2a4f8..effca8a48c 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -1007,7 +1007,6 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee // Validating the Binary Security Token Type used on Automatic Enrollments (returned by STS Auth Endpoint) if binSecToken.Type == mdm_types.WindowsMDMAutomaticEnrollmentType { - upnToken, err := svc.wstepCertManager.GetSTSAuthTokenUPNClaim(binSecToken.Payload.AuthToken) if err != nil { return "", "", ctxerr.Wrap(ctx, err, "issue retrieving UPN from Auth token") @@ -1020,6 +1019,20 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee // Validating the Binary Security Token Type used on Automatic Enrollments if authToken.IsAzureJWTToken() { + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return "", "", ctxerr.Wrap(ctx, err, "retrieving app config for auth token validation") + } + + entraTenantIDs := appConfig.MDM.WindowsEntraTenantIDs.Value + if len(entraTenantIDs) == 0 { + return "", "", ctxerr.New(ctx, "no entra tenant IDs configured for automatic enrollment") + } + expectedURL := appConfig.ServerSettings.ServerURL + expectedURLParsed, err := url.Parse(expectedURL) + if err != nil { + return "", "", ctxerr.Wrap(ctx, err, "parsing server URL for auth token validation") + } // Validate the JWT Auth token by retreving its claims tokenData, err := microsoft_mdm.GetAzureAuthTokenClaims(ctx, authToken.Content) @@ -1027,11 +1040,40 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee return "", "", fmt.Errorf("binary security token claim failed: %v", err) } + hasExpectedAudience := false + for _, aud := range tokenData.Audience { + audURL, err := url.Parse(aud) + // The Audience may have multiple values and not everything in the aud will be a URL and that's OK + if err != nil { + continue + } + if audURL.Host == expectedURLParsed.Host { + hasExpectedAudience = true + break + } + } + if !hasExpectedAudience { + // Log bad audiences here for debugging + level.Error(svc.logger).Log( + "msg", "unexpected token audience in AzureAD Binary Security Token", + "expected_host", expectedURLParsed.Host, + "token_audiences", strings.Join(tokenData.Audience, ","), + ) + return "", "", ctxerr.Errorf(ctx, "token audience is not authorized") + } + if !slices.Contains(entraTenantIDs, tokenData.TenantID) { + level.Error(svc.logger).Log( + "msg", "unexpected token tenant in AzureAD Binary Security Token", + "token_tenant", tokenData.TenantID, + ) + return "", "", ctxerr.New(ctx, "token tenant is not authorized") + } + // No errors, token is authorized return tokenData.UPN, "", nil } - return "", "", errors.New("token is not authorized") + return "", "", ctxerr.New(ctx, "token is not authorized") } // ProcessMDMMicrosoftDiscovery handles the Discovery message validation and response