high · 8.9CVE-2026-58424Jul 21, 2026

CVE-2026-58424: Gitea Actions Fork PR Approval Gate Permanent Bypass

Rohit Hatagale
AI Security Researcher, SecureLayer7

A one-time approval click on a fork pull request permanently disables Gitea's workflow approval gate for that contributor, letting them run arbitrary CI code on the maintainer's runner without ever ne

Packagecode.gitea.io/gitea
Ecosystemgo
Affected< 1.26.3
Fixed in1.26.3

The problem

Gitea Actions enforces an approval gate on fork pull request workflow runs so maintainers can vet untrusted YAML before it executes on their CI infrastructure. The gate lives in `ifNeedApproval()` in `services/actions/notifier_helper.go`.

The final bypass clause queries `action_run` for any row where `(repo_id, trigger_user_id, approved_by > 0)`. It does not check the pull request, the branch, the commit SHA, or any time window. Once a maintainer clicks "Approve and run workflows" on a contributor's very first fork PR, every future fork PR from that contributor skips the gate forever, with `need_approval = 0` and `approved_by = 0` written to the new run row.

No maintainer interaction is ever required again.

Proof of concept

A working proof-of-concept for CVE-2026-58424 in code.gitea.io/gitea, with the exact payload below.

yaml
# .gitea/workflows/ci.yml — pushed in a second fork PR from the same account
# The first PR was approved. This one bypasses the gate entirely.
name: CI
on: [pull_request]
jobs:
  test:
    runs-on: [ubuntu-latest]
    steps:
      - name: pwn
        env:
          BASE_TOKEN: ${{ github.token }}
        run: |
          echo "===== BYPASS DEMO - this workflow ran WITHOUT approval ====="
          echo "running as: $(id)"
          echo "hostname:   $(hostname)"
          echo "uname:      $(uname -a)"
          echo "GITHUB_TOKEN length = ${#BASE_TOKEN}"
          curl -fsS --max-time 5 https://example.com/ -o /dev/null -w "egress HTTP %{http_code}\n"
          ls -la
          echo "===== END DEMO ====="

The root cause is that the unit of trust in `ifNeedApproval()` is the user identity scoped to the repository, not the pull request or commit. The query `db.Count[ActionRun]` with `Approved: true` resolves to `approved_by > 0` and returns any historical row, so a single past approval makes the gate permanently silent for that user.

The patch (PR #38010, commit 699fe2ef) replaces the "has any approved run" check with a "has a PR been merged by this user in this repo" check. That matches the intent documented in GitHub Actions, where first-contributor trust is earned by a merged contribution, not just an approved CI run.

The bypassed run row is schema-indistinguishable from a legitimate trusted-user run: `need_approval = 0`, `approved_by = 0`, status = Success. There is no audit signal separating it from an authorized execution.

The fix

Upgrade to Gitea 1.26.3 or later (upgrade directly to 1.26.4 to avoid a code-page regression in 1.26.3). The fix in PR #38010 replaces the per-user "approved run" check with a per-user "merged PR" check in `ifNeedApproval()`, so the bypass path now requires a contributor to have had a pull request actually merged into the repository before their fork CI runs without approval.

Reported by Prakhar Porwal.

References: [1][2][3][4][5][6]

Related research