GHSA-fj8v-hjwv-qm88 is a medium-severity (CVSS 6.3) CWE-280 vulnerability in gitea.dev. O3 Security confirms whether GHSA-fj8v-hjwv-qm88 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Gitea: Fork-PR Actions task can read a third private repository via the collaborative-owner branch (missing fork-PR guard)
Exploitation Status
No confirmed exploitation observed yet
- CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.
Exploitation and automatability from CISA’s SSVC triage for GHSA-fj8v-hjwv-qm88.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-fj8v-hjwv-qm88 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 0 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
gitea.devReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.
Description
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):
- B is deliberately configured with a collaborative owner — but that is exactly the feature's intended use, so realistic for any deployment using it.
- 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)." - 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
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐹Go | gitea.dev | all versions | 1.27.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for gitea.dev. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update gitea.dev to 1.27.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-fj8v-hjwv-qm88 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-fj8v-hjwv-qm88 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-fj8v-hjwv-qm88. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-fj8v-hjwv-qm88 in your dependencies?
O3 detects GHSA-fj8v-hjwv-qm88 across Go dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.