GitHub Advisoryadvisory · Go

GHSA-777r-4v59-6486 Gitea: Permanent Fork PR Workflow Approval Gate Bypass

HIGH Published 2026-07-21 Modified 2026-07-21 Official page ↗
Aliases
CVE-2026-58424
Ecosystem
Go
Packages
code.gitea.io/gitea
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:H/A:H
Weaknesses
CWE-285, CWE-732, CWE-863
Reviewed
2026-07-21

Affected packages

• Go code.gitea.io/gitea: < 1.26.3 → fixed in 1.26.3

Description

| Field | Value | |-------|-------| | Identifier (researcher-assigned) | GITEA-2026-004 | | Product | Gitea (self-hosted Git service) | | Component | Gitea Actions — fork pull request approval gate | | Affected versions | All Gitea releases `v1.20.0` and later, including the latest `main` (`1.27.0+dev-289-gb7e95cc48c`). The buggy logic was introduced in commit `edf98a2dc3` — *"Require approval to run actions for fork pull request (#22803)"*, 2023-02-24 — and has shipped unchanged since. | | Fixed in | not yet (this disclosure) | | Authentication required | Yes — one unprivileged Gitea account capable of forking the target repository (the default ability for every authenticated user) | | User interaction required | Exactly once — a repository administrator must approve a single benign fork PR's workflow run from the attacker. After that, *no further interaction is ever required* for any future fork PR from the same attacker on the same repository. | | Discovered by | Prakhar Porwal — `[email protected]` | | Live-verified on | Gitea `main` at commit `b7e95cc48cc0e0d6fe24c89bb83da5b84a74490f`, 2026-05-24 | --- 1. Executive summary Gitea Actions enforces an approval gate on workflow runs triggered by fork pull requests, so that an untrusted contributor cannot execute arbitrary workflow YAML on the maintainer's runner infrastructure without explicit consent. The gate is implemented by `ifNeedApproval()` in `services/actions/notifier_helper.go`. Its final clause skips the gate whenever the triggering user has any previously-approved run in the same repository: ```go // services/actions/notifier_helper.go:423-433 if count, err := db.Countactions_model.ActionRun; err != nil { return false, fmt.Errorf("CountRuns: %w", err) } else if count > 0 { log.Trace("do not need approval because user %d has been approved before", user.ID) return false, nil } ``` The check is scoped to `(repo_id, trigger_user_id)` only. It does not consider the pull request, the head commit, the workflow file contents, or any time window. The single approval click on a contributor's *first* fork PR is therefore interpreted by Gitea as *"this user is permanently trusted to execute any workflow YAML on this repository's CI infrastructure forever"* — for every future PR, on any branch, against any commit, regardless of what the workflow does. This is a structural deviation from the documented intent — the in-source comment on the bypassing path reads *"if it's the first time user … triggered actions"*, implying a per-action-trigger check that the code does not actually perform. It is also a deviation from the equivalent behavior on the platform Gitea Actions is modeled after (GitHub Actions), where the first-contributor gate persists until a PR is merged, not merely approved-to-run. I have live-reproduced the bypass end-to-end against a current `main` build. With zero further interaction from the maintainer after the one-time approval, an attacker's second PR's workflow: • Was created with `need_approval = 0` and `approved_by = 0` in the `action_run` table (i.e. nobody ever approved it, and yet it was not gated). • Was dispatched to the runner immediately. • Executed arbitrary shell on the runner, with outbound network access, a populated `GITHUB_TOKEN`, and access to the cloned source. Full receipts are in §3. --- 2. Affected code Primary `services/actions/notifier_helper.go:401-438` — the `ifNeedApproval` function: ```go func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) { // 1. don't need approval if it's not a fork PR // 2. don't need approval if the event is `pull_request_target` since the // workflow will run in the context of base branch if !run.IsForkPullRequest || run.TriggerEvent == actions_module.GithubEventPullRequestTarget { return false, nil } // always need approval if the user is restricted if user.IsRestricted { return true, nil } // don't need approval if the user can write if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil { return false, fmt.Errorf("GetDoerRepoPermission: %w", err) } else if perm.CanWrite(unit_model.TypeActions) { return false, nil } // ===== VULNERABLE BLOCK ================================================== // don't need approval if the user has been approved before if count, err := db.Countactions_model.ActionRun; err != nil { return false, fmt.Errorf("CountRuns: %w", err) } else if count > 0 { return false, nil // 0`: ```go if opts.Approved { cond = cond.And(builder.Gt{"`action_run`.approved_by": 0}) } ``` • `services/actions/approve.go:25-27` — the only writer of `ApprovedBy`, called when a repo admin clicks Approve on a run: ```go run.NeedApproval = false run.ApprovedBy = doer.ID if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { ``` • `models/actions/run.go:44-45` — schema: ```go NeedApproval bool // may need approval if it's a fork pull request ApprovedBy int64 `xorm:"index"` // who approved ``` There is no other writer of `ApprovedBy`, and no resetter for `NeedApproval` once a PR has been approved. The historical row is permanent and unconditional. --- 3. Live reproduction — receipts The following was performed end-to-end against `main` on the date noted. Two accounts were used: • `admin` (uid 1) — repository owner playing the maintainer • `admin2` (uid 3) — second account playing the attacker A fork-mode `gitea-runner v1.0.6` was registered with `ubuntu-latest:host` to enable on-host execution. | Phase | Action | Resulting `action_run` row | |-------|--------|---------------------------| | 1 | `admin` creates `admin/base-repo` with `.gitea/workflows/ci.yml` (`echo hello` baseline) | — | | 2 | `admin2` forks the repo | — | | 3 | `admin2` opens PR #1 with a one-line README edit (no workflow change) | run #5 created, `need_approval = 1`, status = `Blocked` | | 4 | `admin` clicks Approve and run workflows on PR #1 | run #5 updates: `need_approval = 0`, `approved_by = 1` (admin), status → `Success` | | 5 | `admin2` opens PR #3 from a brand-new branch (`bypass-clean`) whose only change is `.gitea/workflows/ci.yml` rewritten to a malicious payload (loud `echo` + `curl` + `id` + `ls -la`) | run #6 created, `need_approval = 0`, `approved_by = 0` — gate skipped at creation | | — | runner dispatches run #6 within seconds, no maintainer interaction at all | run #6 → status = `Success` | Final `action_run` table contents (queried directly from SQLite) ``` id triggered_by_uid need_approval approved_by_uid status_text ref sha -- ---------------- ------------- --------------- ----------- ---------------- -------- 5 3 0 1 Success refs/pull/1/head 463c9114 6 3 0 0 Success refs/pull/3/head 70db2b9e ``` Row 6 is the smoking gun: `triggered_by` is `…

Related

Alias · CVE (1)

  • CVE-2026-58424 Permanent Fork PR Workflow Approval Gate Bypass vulnerability

Weakness · CWE (3)

  • CWE-285 Improper Authorization weakness
  • CWE-732 Incorrect Permission Assignment for Critical Resource weakness
  • CWE-863 Incorrect Authorization weakness

References