Fleet: Authorization Bypass in certificate template batch deletion for team administrators
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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/fleetdm/fleet/v4Go | < 4.80.1 | 4.80.1 |
Affected products
1Patches
1d27d0362db39Optimizing certificate template batch delete auth (#38650)
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- github.com/advisories/GHSA-5jvp-m9h4-253hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25963ghsaADVISORY
- github.com/fleetdm/fleet/commit/d27d0362db390fe835e3b5328525f25018df0fb7ghsaWEB
- github.com/fleetdm/fleet/security/advisories/GHSA-5jvp-m9h4-253hghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.