CVE-2026-58416
Published:July 21, 2026
Updated:July 22, 2026
Summary "GetActionsUserRepoPermission" ("models/perm/access/repo_permission.go") decides whether an Actions task token may access a target repo. Its cross-repo branches each enforce a fork-PR discriminator — except the collaborative-owner branch, which is missing the "!task.IsForkPullRequest" guard that its sibling has. As a result, when a private repo B lists owner A as a collaborative owner, an attacker-controlled fork pull-request workflow whose base repo is owned by A is granted code-read on B — i.e. the fork's YAML can clone a third private repository it has no rights to. Details // models/perm/access/repo_permission.go (v1.26.2), in GetActionsUserRepoPermission if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) { // passes isForkPR -> denies forks return maxPerm, nil } ... if taskRepo.IsPrivate { // <-- NO IsForkPullRequest check here actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) { return maxPerm, nil // grants code-read to target repo B } } The sibling same-owner path correctly denies fork PRs: func checkSameOwnerCrossRepoAccess(ctx, taskRepo, targetRepo, isForkPR bool) bool { if isForkPR { return false // Fork PRs are never allowed cross-repo access to other private repositories. } ... } "taskRepo" = the repo whose workflow is running (the PR's base repo A); "repo" = the target being cloned (B). "IsCollaborativeOwner(taskRepo.OwnerID)" asks "does target B's Actions config trust A's owner for cross-repo read?" When B trusts ownerA, the branch returns "maxPerm" (code-read) even when "task.IsForkPullRequest" is true — i.e. when the executing YAML is the fork's, not A's. Every sibling enforces the fork-PR discriminator; except for this branch: "checkSameOwnerCrossRepoAccess" denies forks; "ComputeTaskTokenPermissions" ("models/actions/token_permissions.go") only clamps the token ceiling to read-only for fork/cross-repo (its own comment notes the access decision is in "GetActionsUserRepoPermission", so it does not neutralize the gap — it just makes the leak read-only); secrets ("models/secret/secret.go") and the approval gate ("services/actions/notifier_helper.go") both correctly key on "IsForkPullRequest". Reachability — the runner clones target repo B over git-HTTP with the task token: "routers/web/repo/githttp.go" → "GetDoerRepoPermission(ctx, repoB, ActionsUser)" → "GetActionsUserRepoPermission(ctx, repoB, actionsUser, taskID)" with "IsForkPullRequest == true" → collaborative-owner branch returns code-read → "p.CanAccess(Read, code)" passes → private clone of B succeeds. ("CheckRepoScopedToken" in githttp is a no-op for the Actions token.) PoC Setup: private base repo A ("usera/repoA"), private third repo B ("userb/repoB") with a planted "SECRET.txt", B's Actions config trusting "usera" as a collaborative owner, and a genuine running fork-PR task token ("token_hash" computed with Gitea's own "HashToken") presented as HTTP Basic. Requesting "GET /userb/repoB.git/info/refs?service=git-upload-pack": | Condition (same fork-PR token) | HTTP | Meaning | |---|---|---| | anonymous (no token) | 401 | auth required | | token, A public, B trusts A | 404 | branch gated on "taskRepo.IsPrivate" ⇒ A public skips it | | token, A private, B has no collab-owner config | 404 | no trust ⇒ denied | | token, A private, B trusts A (collab-owner) | 200 | "git clone" of private B succeeds | | config removed / restored | 404 / 200 | deterministic | In the 200 case, "git clone" of private repo B succeeded and yielded its "SECRET.txt" — the full source of a third private repo the fork-PR author has no rights to. Impact Read-only confidentiality breach: discloses the full source of a third private repository (B) to an untrusted external fork-PR author. Read-only, not write/RCE. Preconditions (honest): 1. B is deliberately configured with a collaborative owner — but that is exactly the feature's intended use, so realistic for any deployment using it. 2. The fork PR's base repo A is itself private (the branch is gated on "taskRepo.IsPrivate"). Forking a private A already requires read on A, so this is a normal internal-contributor situation, not a weakening — the escalation is "read A (granted) → read a different private repo B (never granted)." 3. The fork-PR workflow must actually run — most realistically via an attacker who had one earlier PR approved (the "approved before" path in "ifNeedApproval"), after which fork PRs auto-run. Suggested remediation Add the same fork-PR guard the sibling path has (one line): if taskRepo.IsPrivate && !task.IsForkPullRequest { actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) { return maxPerm, nil } } This flips "Vuln_ForkPR_LeaksThirdPrivateRepo" to PASS, keeps "Control_NonFork_Allowed" PASS (legitimate collaborative-owner sharing still works), and leaves the existing "TestGetActionsUserRepoPermission" suite all green.
Affected Packages
https://github.com/go-gitea/gitea.git (GITHUB):
Affected version(s) >=v0.9.99 <v1.27.0Fix Suggestion:
Update to version v1.27.0gitea.dev (GO):
Affected version(s) >=v0.9.99 <v1.27.0Fix Suggestion:
Update to version v1.27.0Related Resources (5)
Do you need more information?
Contact UsCVSS v4
Base Score:
7
Attack Vector
NETWORK
Attack Complexity
HIGH
Attack Requirements
NONE
Privileges Required
LOW
User Interaction
NONE
Vulnerable System Confidentiality
HIGH
Vulnerable System Integrity
NONE
Vulnerable System Availability
NONE
Subsequent System Confidentiality
HIGH
Subsequent System Integrity
NONE
Subsequent System Availability
NONE
CVSS v3
Base Score:
6.3
Attack Vector
NETWORK
Attack Complexity
HIGH
Privileges Required
LOW
User Interaction
NONE
Scope
CHANGED
Confidentiality
HIGH
Integrity
NONE
Availability
NONE