VYPR
Moderate severityNVD Advisory· Published Feb 26, 2026· Updated Feb 26, 2026

Fleet: Authorization Bypass in certificate template batch deletion for team administrators

CVE-2026-25963

Description

Fleet is open source device management software. In versions prior to 4.80.1, a broken authorization check in Fleet’s certificate template deletion API could allow a team administrator to delete certificate templates belonging to other teams within the same Fleet instance. Fleet supports certificate templates that are scoped to individual teams. In affected versions, the batch deletion endpoint validated authorization using a user-supplied team identifier but did not verify that the certificate template IDs being deleted actually belonged to that team. As a result, a team administrator could delete certificate templates associated with other teams, potentially disrupting certificate-based workflows such as device enrollment, Wi-Fi authentication, VPN access, or other certificate-dependent configurations for the affected teams. This issue does not allow privilege escalation, access to sensitive data, or compromise of Fleet’s control plane. Impact is limited to integrity and availability of certificate templates across teams. Version 4.80.1 patches the issue. If an immediate upgrade is not possible, administrators should restrict access to certificate template management to trusted users and avoid delegating team administrator permissions where not strictly required.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/fleetdm/fleet/v4Go
< 4.80.14.80.1

Affected products

1

Patches

1
d27d0362db39

Optimizing certificate template batch delete auth (#38650)

https://github.com/fleetdm/fleetKonstantin SykulevJan 24, 2026via ghsa
8 files changed · +290 24
  • changes/13836-cert-batch-del+1 0 added
    @@ -0,0 +1 @@
    +* Optimizing certificate template batch delete auth
    \ No newline at end of file
    
  • cmd/fleetctl/fleetctl/gitops_test.go+52 0 modified
    @@ -3899,6 +3899,20 @@ func setupAndroidCertificatesTestMocks(t *testing.T, ds *mock.Store) []*fleet.Ce
     		}, nil
     	}
     
    +	ds.GetCertificateTemplatesByIdsAndTeamFunc = func(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error) {
    +		var results []*fleet.CertificateTemplateResponse
    +		for _, id := range ids {
    +			results = append(results, &fleet.CertificateTemplateResponse{
    +				CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
    +					ID:   id,
    +					Name: fmt.Sprintf("Certificate %d", id),
    +				},
    +				TeamID: teamID,
    +			})
    +		}
    +		return results, nil
    +	}
    +
     	return certAuthorities
     }
     
    @@ -4260,6 +4274,21 @@ func TestGitOpsAndroidCertificatesDeleteOne(t *testing.T) {
     		return existing, &fleet.PaginationMetadata{}, nil
     	}
     
    +	ds.GetCertificateTemplatesByIdsAndTeamFunc = func(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error) {
    +		existing := []*fleet.CertificateTemplateResponse{
    +			{
    +				CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
    +					ID:                     1,
    +					Name:                   "Certificate 1",
    +					CertificateAuthorityId: 1,
    +				},
    +				TeamID: teamID,
    +			},
    +		}
    +
    +		return existing, nil
    +	}
    +
     	ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, certificateTemplateIDs uint) error {
     		return nil
     	}
    @@ -4356,6 +4385,29 @@ func TestGitOpsAndroidCertificatesDeleteAll(t *testing.T) {
     		return existing, &fleet.PaginationMetadata{}, nil
     	}
     
    +	ds.GetCertificateTemplatesByIdsAndTeamFunc = func(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error) {
    +		existing := []*fleet.CertificateTemplateResponse{
    +			{
    +				CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
    +					ID:                     1,
    +					Name:                   "Certificate 1",
    +					CertificateAuthorityId: 1,
    +				},
    +				TeamID: teamID,
    +			},
    +			{
    +				CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
    +					ID:                     2,
    +					Name:                   "Certificate 2",
    +					CertificateAuthorityId: 2,
    +				},
    +				TeamID: teamID,
    +			},
    +		}
    +
    +		return existing, nil
    +	}
    +
     	ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, certificateTemplateIDs uint) error {
     		return nil
     	}
    
  • server/datastore/mysql/certificate_templates.go+37 24 modified
    @@ -13,20 +13,23 @@ import (
     	"github.com/jmoiron/sqlx"
     )
     
    +const certificateTemplateResponseSql = `
    +	SELECT
    +		certificate_templates.id,
    +		certificate_templates.name,
    +		certificate_templates.team_id,
    +		certificate_templates.subject_name,
    +		certificate_templates.created_at,
    +		certificate_authorities.id AS certificate_authority_id,
    +		certificate_authorities.name AS certificate_authority_name,
    +		certificate_authorities.type AS certificate_authority_type
    +	FROM certificate_templates
    +	INNER JOIN certificate_authorities ON certificate_templates.certificate_authority_id = certificate_authorities.id
    +`
    +
     func (ds *Datastore) GetCertificateTemplateById(ctx context.Context, id uint) (*fleet.CertificateTemplateResponse, error) {
     	var template fleet.CertificateTemplateResponse
    -	if err := sqlx.GetContext(ctx, ds.reader(ctx), &template, `
    -		SELECT
    -			certificate_templates.id,
    -			certificate_templates.name,
    -			certificate_templates.team_id,
    -			certificate_templates.subject_name,
    -			certificate_templates.created_at,
    -			certificate_authorities.id AS certificate_authority_id,
    -			certificate_authorities.name AS certificate_authority_name,
    -			certificate_authorities.type AS certificate_authority_type
    -		FROM certificate_templates
    -		INNER JOIN certificate_authorities ON certificate_templates.certificate_authority_id = certificate_authorities.id
    +	if err := sqlx.GetContext(ctx, ds.reader(ctx), &template, certificateTemplateResponseSql+`
     		WHERE certificate_templates.id = ?
     	`, id); err != nil {
     		if errors.Is(err, sql.ErrNoRows) {
    @@ -38,20 +41,30 @@ func (ds *Datastore) GetCertificateTemplateById(ctx context.Context, id uint) (*
     	return &template, nil
     }
     
    +func (ds *Datastore) GetCertificateTemplatesByIdsAndTeam(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error) {
    +	var certificateTemplates []*fleet.CertificateTemplateResponse
    +
    +	if len(ids) == 0 {
    +		return certificateTemplates, nil
    +	}
    +	// for no team pass 0 as teamID
    +	query, args, err := sqlx.In(certificateTemplateResponseSql+`
    +		WHERE certificate_templates.team_id = ? AND certificate_templates.id IN (?)
    +	`, teamID, ids)
    +	if err != nil {
    +		return nil, ctxerr.Wrap(ctx, err, "building query for certificate_templates by team id and ids")
    +	}
    +
    +	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &certificateTemplates, query, args...); err != nil {
    +		return nil, ctxerr.Wrap(ctx, err, "query certificate_template by team id and ids")
    +	}
    +
    +	return certificateTemplates, nil
    +}
    +
     func (ds *Datastore) GetCertificateTemplateByTeamIDAndName(ctx context.Context, teamID uint, name string) (*fleet.CertificateTemplateResponse, error) {
     	var template fleet.CertificateTemplateResponse
    -	if err := sqlx.GetContext(ctx, ds.reader(ctx), &template, `
    -		SELECT
    -			certificate_templates.id,
    -			certificate_templates.name,
    -			certificate_templates.team_id,
    -			certificate_templates.subject_name,
    -			certificate_templates.created_at,
    -			certificate_authorities.id AS certificate_authority_id,
    -			certificate_authorities.name AS certificate_authority_name,
    -			certificate_authorities.type AS certificate_authority_type
    -		FROM certificate_templates
    -		INNER JOIN certificate_authorities ON certificate_templates.certificate_authority_id = certificate_authorities.id
    +	if err := sqlx.GetContext(ctx, ds.reader(ctx), &template, certificateTemplateResponseSql+`
     		WHERE certificate_templates.team_id = ? AND certificate_templates.name = ?
     	`, teamID, name); err != nil {
     		return nil, ctxerr.Wrap(ctx, err, "getting certificate_template by team id and name")
    
  • server/datastore/mysql/certificate_templates_test.go+105 0 modified
    @@ -22,6 +22,7 @@ func TestCertificates(t *testing.T) {
     	}{
     		{"CreateCertificateTemplate", testCreateCertificateTemplate},
     		{"GetCertificateTemplateById", testGetCertificateTemplateByID},
    +		{"GetCertificateTemplatesByIdsAndTeam", testGetCertificateTemplatesByIdsAndTeam},
     		{"GetCertificateTemplatesByTeamID", testGetCertificateTemplatesByTeamID},
     		{"DeleteCertificateTemplate", testDeleteCertificateTemplate},
     		{"BatchUpsertCertificates", testBatchUpsertCertificates},
    @@ -366,6 +367,110 @@ func testGetCertificateTemplateByID(t *testing.T, ds *Datastore) {
     	}
     }
     
    +func testGetCertificateTemplatesByIdsAndTeam(t *testing.T, ds *Datastore) {
    +	ctx := context.Background()
    +
    +	var teamID, erroneousId uint
    +	var IDs []uint
    +	testCases := []struct {
    +		name     string
    +		before   func(ds *Datastore)
    +		testFunc func(*testing.T, *Datastore)
    +	}{
    +		{
    +			"No existing certificate template",
    +			func(ds *Datastore) {
    +				IDs = make([]uint, 0)
    +			},
    +			func(t *testing.T, ds *Datastore) {
    +				templates, err := ds.GetCertificateTemplatesByIdsAndTeam(ctx, IDs, 0)
    +				require.NoError(t, err)
    +				require.Len(t, templates, 0)
    +			},
    +		},
    +		{
    +			"Get existing certificate template",
    +			func(ds *Datastore) {
    +				// Create test team 1
    +				team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Test Team 1"})
    +				require.NoError(t, err)
    +				teamID = team1.ID
    +
    +				// Create test team 2
    +				team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Test Team 2"})
    +				require.NoError(t, err)
    +
    +				// Create a test certificate authority
    +				ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
    +					Type:      string(fleet.CATypeCustomSCEPProxy),
    +					Name:      ptr.String("Test SCEP CA"),
    +					URL:       ptr.String("http://localhost:8080/scep"),
    +					Challenge: ptr.String("test-challenge"),
    +				})
    +				require.NoError(t, err)
    +				caID := ca.ID
    +
    +				// team 1 certificate
    +				certificateTemplate := fleet.CertificateTemplate{
    +					Name:                   "Cert1",
    +					TeamID:                 teamID,
    +					CertificateAuthorityID: caID,
    +					SubjectName:            "CN=Test Subject 1",
    +				}
    +				res, err := ds.writer(ctx).ExecContext(ctx,
    +					"INSERT INTO certificate_templates (name, team_id, certificate_authority_id, subject_name) VALUES (?, ?, ?, ?)",
    +					certificateTemplate.Name,
    +					certificateTemplate.TeamID,
    +					certificateTemplate.CertificateAuthorityID,
    +					certificateTemplate.SubjectName,
    +				)
    +				require.NoError(t, err)
    +				lastID, err := res.LastInsertId()
    +				require.NoError(t, err)
    +				certificateTemplateID := uint(lastID) //nolint:gosec
    +				IDs = append(IDs, certificateTemplateID)
    +
    +				// team 2 certificates
    +				certificateTemplate = fleet.CertificateTemplate{
    +					Name:                   "Cert2",
    +					TeamID:                 team2.ID,
    +					CertificateAuthorityID: caID,
    +					SubjectName:            "CN=Test Subject 2",
    +				}
    +				res, err = ds.writer(ctx).ExecContext(ctx,
    +					"INSERT INTO certificate_templates (name, team_id, certificate_authority_id, subject_name) VALUES (?, ?, ?, ?)",
    +					certificateTemplate.Name,
    +					certificateTemplate.TeamID,
    +					certificateTemplate.CertificateAuthorityID,
    +					certificateTemplate.SubjectName,
    +				)
    +				require.NoError(t, err)
    +				lastID, err = res.LastInsertId()
    +				require.NoError(t, err)
    +				erroneousId = uint(lastID) //nolint:gosec
    +				IDs = append(IDs, erroneousId)
    +			},
    +			func(t *testing.T, ds *Datastore) {
    +				templates, err := ds.GetCertificateTemplatesByIdsAndTeam(ctx, IDs, teamID)
    +				require.NoError(t, err)
    +				require.Len(t, templates, 1)
    +				require.Equal(t, teamID, templates[0].TeamID)
    +				require.NotEqual(t, erroneousId, templates[0].ID)
    +			},
    +		},
    +	}
    +
    +	for _, tc := range testCases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			defer TruncateTables(t, ds)
    +
    +			tc.before(ds)
    +
    +			tc.testFunc(t, ds)
    +		})
    +	}
    +}
    +
     func testGetCertificateTemplatesByTeamID(t *testing.T, ds *Datastore) {
     	ctx := context.Background()
     
    
  • server/fleet/datastore.go+2 0 modified
    @@ -2553,6 +2553,8 @@ type Datastore interface {
     	GetCertificateTemplateByIdForHost(ctx context.Context, id uint, hostUUID string) (*CertificateTemplateResponseForHost, error)
     	// GetCertificateTemplatesByTeamID gets all certificate templates for a team.
     	GetCertificateTemplatesByTeamID(ctx context.Context, teamID uint, opts ListOptions) ([]*CertificateTemplateResponseSummary, *PaginationMetadata, error)
    +	// GetCertificateTemplatesByIdsAndTeam gets certificate templates by team ID and a list of certificate template IDs.
    +	GetCertificateTemplatesByIdsAndTeam(ctx context.Context, ids []uint, teamID uint) ([]*CertificateTemplateResponse, error)
     	// GetCertificateTemplateByTeamIDAndName gets a certificate template by team ID and name.
     	GetCertificateTemplateByTeamIDAndName(ctx context.Context, teamID uint, name string) (*CertificateTemplateResponse, error)
     	// ListAndroidHostUUIDsWithDeliverableCertificateTemplates returns a paginated list of Android host UUIDs that have certificate templates.
    
  • server/mock/datastore_mock.go+12 0 modified
    @@ -1699,6 +1699,8 @@ type GetCertificateTemplateByIdForHostFunc func(ctx context.Context, id uint, ho
     
     type GetCertificateTemplatesByTeamIDFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error)
     
    +type GetCertificateTemplatesByIdsAndTeamFunc func(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error)
    +
     type GetCertificateTemplateByTeamIDAndNameFunc func(ctx context.Context, teamID uint, name string) (*fleet.CertificateTemplateResponse, error)
     
     type ListAndroidHostUUIDsWithDeliverableCertificateTemplatesFunc func(ctx context.Context, offset int, limit int) ([]string, error)
    @@ -4260,6 +4262,9 @@ type DataStore struct {
     	GetCertificateTemplatesByTeamIDFunc        GetCertificateTemplatesByTeamIDFunc
     	GetCertificateTemplatesByTeamIDFuncInvoked bool
     
    +	GetCertificateTemplatesByIdsAndTeamFunc        GetCertificateTemplatesByIdsAndTeamFunc
    +	GetCertificateTemplatesByIdsAndTeamFuncInvoked bool
    +
     	GetCertificateTemplateByTeamIDAndNameFunc        GetCertificateTemplateByTeamIDAndNameFunc
     	GetCertificateTemplateByTeamIDAndNameFuncInvoked bool
     
    @@ -10198,6 +10203,13 @@ func (s *DataStore) GetCertificateTemplatesByTeamID(ctx context.Context, teamID
     	return s.GetCertificateTemplatesByTeamIDFunc(ctx, teamID, opts)
     }
     
    +func (s *DataStore) GetCertificateTemplatesByIdsAndTeam(ctx context.Context, ids []uint, teamID uint) ([]*fleet.CertificateTemplateResponse, error) {
    +	s.mu.Lock()
    +	s.GetCertificateTemplatesByIdsAndTeamFuncInvoked = true
    +	s.mu.Unlock()
    +	return s.GetCertificateTemplatesByIdsAndTeamFunc(ctx, ids, teamID)
    +}
    +
     func (s *DataStore) GetCertificateTemplateByTeamIDAndName(ctx context.Context, teamID uint, name string) (*fleet.CertificateTemplateResponse, error) {
     	s.mu.Lock()
     	s.GetCertificateTemplateByTeamIDAndNameFuncInvoked = true
    
  • server/service/certificates.go+18 0 modified
    @@ -543,9 +543,27 @@ func deleteCertificateTemplateSpecsEndpoint(ctx context.Context, request interfa
     }
     
     func (svc *Service) DeleteCertificateTemplateSpecs(ctx context.Context, certificateTemplateIDs []uint, teamID uint) error {
    +	// Authorize team
     	if err := svc.authz.Authorize(ctx, &fleet.CertificateTemplate{TeamID: teamID}, fleet.ActionWrite); err != nil {
     		return err
     	}
    +	// Authorize all ids are on team
    +	certificateTemplates, err := svc.ds.GetCertificateTemplatesByIdsAndTeam(ctx, certificateTemplateIDs, teamID)
    +	if err != nil {
    +		return err
    +	}
    +	uniqueIDs := make(map[uint]struct{}, len(certificateTemplateIDs))
    +	for _, id := range certificateTemplateIDs {
    +		uniqueIDs[id] = struct{}{}
    +	}
    +	if len(uniqueIDs) != len(certificateTemplates) {
    +		return authz.ForbiddenWithInternal(
    +			"can only delete templates from team parameter",
    +			authz.UserFromContext(ctx),
    +			&fleet.CertificateTemplate{TeamID: teamID},
    +			fleet.ActionWrite,
    +		)
    +	}
     
     	deletedRows, err := svc.ds.BatchDeleteCertificateTemplates(ctx, certificateTemplateIDs)
     	if err != nil {
    
  • server/service/integration_core_test.go+63 0 modified
    @@ -8305,6 +8305,69 @@ func (s *integrationTestSuite) TestCertificatesSpecs() {
     	s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &getCertResp)
     	require.NotNil(t, getCertResp.Certificate)
     
    +	// Delete is authorized properly
    +	observerEmail := "observer-cert-test@fleetdm.com"
    +	observerPwd := test.GoodPassword
    +	observerUser := &fleet.User{
    +		Name:       "Observer User",
    +		Email:      observerEmail,
    +		GlobalRole: ptr.String(fleet.RoleObserver),
    +	}
    +	require.NoError(t, observerUser.SetPassword(observerPwd, 10, 10))
    +	_, err = s.ds.NewUser(ctx, observerUser)
    +	require.NoError(t, err)
    +
    +	// Switch to observer user
    +	s.token = s.getCachedUserToken(observerEmail, observerPwd)
    +	// just in case test fails, restore to admin
    +	defer func() { s.token = s.getTestAdminToken() }()
    +
    +	// Delete with observer
    +	resp = s.Do("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{
    +		"ids":     []uint{savedNoTeamCertTemplates[0].ID},
    +		"team_id": uint(0), // "No team"
    +	}, http.StatusForbidden)
    +	resp.Body.Close()
    +
    +	// Switch back to admin
    +	s.token = s.getTestAdminToken()
    +
    +	// Verify the certificate still exists (wasn't deleted by observer)
    +	s.DoJSON("GET", "/api/latest/fleet/certificates", nil, http.StatusOK, &noTeamCertificatesResp)
    +	found := false
    +	for _, cert := range noTeamCertificatesResp.Certificates {
    +		if cert.ID == savedNoTeamCertTemplates[0].ID {
    +			found = true
    +			break
    +		}
    +	}
    +	require.True(t, found, "Certificate should not be deleted by observer user")
    +
    +	// Cannot delete certificates from a different team
    +	// Create team 2
    +	team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Test Team 2"})
    +	require.NoError(t, err)
    +	team2ID := team2.ID
    +	// Create a certificate in team2
    +	var team2CertResp createCertificateTemplateResponse
    +	s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{
    +		Name:                   "Team 2 Cert",
    +		TeamID:                 team2ID,
    +		CertificateAuthorityId: ca.ID,
    +		SubjectName:            "CN=$FLEET_VAR_HOST_UUID",
    +	}, http.StatusOK, &team2CertResp)
    +	var forbiddenDelResp deleteCertificateTemplateSpecsResponse
    +	// Delete with team 1 id and certificate from team 2
    +	s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{
    +		"ids":     []uint{team2CertResp.ID},
    +		"team_id": team.ID,
    +	}, http.StatusForbidden, &forbiddenDelResp)
    +	var listTeam2Resp listCertificateTemplatesResponse
    +	s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", team2ID),
    +		nil, http.StatusOK, &listTeam2Resp)
    +	require.Len(t, listTeam2Resp.Certificates, 1)
    +	require.Equal(t, "Team 2 Cert", listTeam2Resp.Certificates[0].Name)
    +
     	var delResp deleteCertificateTemplateResponse
     	s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", createCertResp.ID), nil, http.StatusOK, &delResp)
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.