Summary

Gitea: Permanent Fork PR Workflow Approval Gate Bypass

Advisory details

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 — prakharporwal2004@gmail.com
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:

// services/actions/notifier_helper.go:423-433
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
    RepoID:        repo.ID,
    TriggerUserID: user.ID,
    Approved:      true,
}); 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:

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.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
        RepoID:        repo.ID,
        TriggerUserID: user.ID,
        Approved:      true,
    }); err != nil {
        return false, fmt.Errorf("CountRuns: %w", err)
    } else if count > 0 {
        return false, nil   // <-- permanent, unscoped bypass
    }
    // =========================================================================

    return true, nil
}

Called from handleWorkflows() (services/actions/notifier_helper.go:339) for every workflow run created in response to a fork PR; the return value populates the NeedApproval column on the action_run row.

Supporting

  • models/actions/run_list.go:84-86 — the FindRunOptions.Approved filter resolves to approved_by > 0:
    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:
    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:
    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

References