GHSA-q423-49rw-g9mh
MEDIUMGHSA-q423-49rw-g9mh is a medium-severity (CVSS 4.3) Information Exposure vulnerability in code.gitea.io/gitea. O3 Security confirms whether GHSA-q423-49rw-g9mh is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Gitea: GHSA-8fwc-qjw5-rvgp ClearRepoWatches fix not applied to API EditRepo path — sister code path retains stale watches on public->private
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-q423-49rw-g9mh.
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-q423-49rw-g9mh 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 369,023 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
code.gitea.io/giteaReal-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
GHSA-8fwc-qjw5-rvgp ("Gitea may send release notification emails for private repositories to users whose access has been revoked", fix in PR #36319 / commit 8a98ac22) added repo_model.ClearRepoWatches as a defense for the state transition public→private. The cleanup was wired into services/repository/repository.go::MakeRepoPrivate only. The sister helper services/repository/repository.go::updateRepository — which is the function used by the API path PATCH /api/v1/repos/{owner}/{repo} — was not patched and still calls ClearRepoStars only.
As a result, when a public repository is flipped to private via the REST API (rather than via the web Settings → Danger Zone UI), the watch records persist. Affected users can:
- See the now-private repository in
GET /api/v1/user/subscriptions?private=truealong with its fullRepositoryJSON (description, default branch, language, fork status, counts, mirror metadata, license list, etc.) — even though they have no access to the repository. - Have their stale watch records re-leak content through any future notification path that does not include the send-time
CheckRepoUnitUsercheck that was added toservices/mailer/mail_release.go. - Inflate the visible
NumWatchescounter on the repository.
Severity
Medium — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Same impact class as the original GHSA-8fwc-qjw5-rvgp (which was classified Medium). The send-time mail filter added in the same PR mitigates the release-content disclosure vector. The residual leak is repo metadata via the subscriptions endpoint and stale watcher counts.
CWE-281 (Improper Preservation of Permissions), CWE-359 (Exposure of Private Personal Information), CWE-200 (Exposure of Sensitive Information).
Affected Versions
Every release starting from v1.25.4 (the release shipping the original GHSA-8fwc-qjw5-rvgp fix) through HEAD (master @ ef801bb6, 2026-05-16). The follow-up refactor in commit 943ff752 (PR #36959, 2026-03-24) which merged MakeRepoPublic+MakeRepoPrivate did not propagate the ClearRepoWatches call to updateRepository.
Affected Component
services/repository/repository.go:240-302—func updateRepository(ctx, repo, visibilityChanged bool), the sister helper called via the API path. Clears stars on line 273 but never callsClearRepoWatches.routers/api/v1/repo/repo.go::Edit(line 573) →updateBasicProperties(line 678-697) →repo_service.UpdateRepository(ctx, repo, visibilityChanged)(line 726) — the API path that exercises the sister helper.
Steps to Reproduce
The bug is observable purely from static analysis; the live PoC is straightforward.
-
Start a Gitea instance at any release from v1.25.4 onwards (verified static at HEAD
ef801bb6). -
Create users
A(org admin) andB(member). Create a public repositoryA/proj. AsB, watch the repo:curl -u B:<token> -X PUT "https://gitea.example.com/api/v1/repos/A/proj/subscription" -
As
A, flip the repo to private via the REST API (not via the web UI):curl -u A:<token> -X PATCH "https://gitea.example.com/api/v1/repos/A/proj" \ -H 'Content-Type: application/json' \ -d '{"private": true}' -
As
B, listB's watched repos withprivate=true:curl -u B:<token> "https://gitea.example.com/api/v1/user/subscriptions"The now-private repo
A/projis returned inB's subscription list, with the fullRepositorypayload — includingdescription,default_branch,language,topics,license, fork/branch/issue/release counts, etc. -
Compare with the web-UI path (which IS patched). As
A, flip a different public repoA/proj2to private via Settings → Danger Zone → "Make this repository private" (which callsrepo_service.MakeRepoPrivate). VerifyB's subscription list no longer containsA/proj2.
The asymmetry of outcomes between steps 4 and 5 — for the same state transition — is the gap.
Direct Evidence (no PoC needed)
$ gh api repos/go-gitea/gitea/contents/services/repository/repository.go \
--jq .content | base64 -d | grep -n "ClearRepoWatches\|ClearRepoStars"
154: if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
157: if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil { # MakeRepoPrivate
273: if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil { # updateRepository — ClearRepoWatches missing here
The fix-author's own test file confirms the asymmetry:
$ gh api repos/go-gitea/gitea/contents/services/repository/repository_test.go --jq .content | base64 -d | grep -n "Test.*VisibilityChanged\|Test.*ClearsWatches"
44:func TestUpdateRepositoryVisibilityChanged(t *testing.T) { # only checks act.IsPrivate
73:func TestMakeRepoPrivateClearsWatches(t *testing.T) { # checks watches are cleared
TestUpdateRepositoryVisibilityChanged explicitly calls updateRepository(ctx, repo, true) (line 53) and asserts act.IsPrivate (line 61) — but never verifies GetRepoWatchersIDs returns empty, while the parallel TestMakeRepoPrivateClearsWatches does. The test asymmetry mirrors the fix asymmetry.
Impact
- An organization that uses terraform-gitea or any other REST-API-driven automation to flip repositories private (the canonical IaC pattern) hits
updateRepository, notMakeRepoPrivate. - An organization using the official Gitea SDK (
go-sdk,py-gitea, etc.) orcurlscripts to make repos private after an internal policy change hits the same path. - Multi-tenant Gitea-as-a-service operators with API-driven repository-lifecycle endpoints are exposed.
Stale watch rows leak through GET /user/subscriptions (with the watcher's own credentials), GET /repos/{owner}/{repo}/subscribers (stale NumWatches total), and become a re-leak surface for any future notification path that forgets the send-time access check.
Suggested Fix
Drop-in mirror of the call already present in MakeRepoPrivate. In services/repository/repository.go::updateRepository, inside the existing if repo.IsPrivate { ... } branch (around line 265-276), add the ClearRepoWatches call directly after ClearRepoStars:
// services/repository/repository.go
func updateRepository(ctx context.Context, repo *repo_model.Repository, visibilityChanged bool) (err error) {
...
if visibilityChanged {
...
// If repo has become private, we need to set its actions to private.
if repo.IsPrivate {
_, err = e.Where("repo_id = ?", repo.ID).Cols("is_private").Update(&activities_model.Action{
IsPrivate: true,
})
if err != nil {
return err
}
if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
return err
}
// Match MakeRepoPrivate's behavior — see PR #36319 / GHSA-8fwc-qjw5-rvgp.
// Stale watch rows on a now-private repo leak repository metadata to ex-watchers
// via GET /user/subscriptions?private=true and through any future notification
// path that does not have a send-time access check.
if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
return err
}
}
...
}
...
}
Regression test (mirror of TestMakeRepoPrivateClearsWatches) to add to services/repository/repository_test.go:
func TestUpdateRepositoryClearsWatchesOnVisibilityChange(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo.IsPrivate)
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
require.NoError(t, err)
require.NotEmpty(t, watchers)
repo.IsPrivate = true
assert.NoError(t, updateRepository(t.Context(), repo, true))
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
assert.NoError(t, err)
assert.Empty(t, watchers)
updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Zero(t, updatedRepo.NumWatches)
}
Optional belt-and-suspenders: an integration test against PATCH /api/v1/repos/{owner}/{repo} with body {"private": true} that exercises the entire API path through routers/api/v1/repo/repo.go::Edit.
Discovery Methodology
This finding follows the "sister-fix-incomplete" lens that has been productive across several recent reports: pick a recent advisory where the fix lands as a single PR touching one function, then grep the codebase for parallel call sites that should have received the same defense.
For Gitea:
- Enumerate recent advisories via
gh api graphql ... securityVulnerabilities(ecosystem: GO, package: code.gitea.io/gitea). - GHSA-8fwc-qjw5-rvgp stood out because the description names a state transition (public→private) — a class of bug where the defense is typically wired into a single helper.
gh api repos/.../commits/8a98ac221 --jq '.files[] | .filename'listed the fix files;ClearRepoWatcheswas added to one function.grep -rn "ClearRepoWatches\|MakeRepoPrivate"revealed two functions inservices/repository/repository.gothat handle the state transition:MakeRepoPrivate(patched) and the lowercase sisterupdateRepository(unpatched).- Walked the call chain from
routers/api/v1/repo/repo.go::Editto confirm the API path usesupdateRepository, notMakeRepoPrivate.
Pre-emptive rebuttals
- "The send-time filter in
MailNewReleasealready blocks release-content disclosure" — Correct, and acknowledged. The residual leak this report concerns is metadata via theGET /user/subscriptionsendpoint and staleNumWatches. The send-time filter is a necessary but not sufficient defense; theClearRepoWatchescall is the persistence-side belt-and-suspenders that the original PR author explicitly added. - "The web UI is the supported path; the API path is not in scope" — The API is documented public surface (swagger spec in
templates/swagger/v1_json.tmpl), Gitea ships official SDKs (go-sdk,py-gitea) that use exactly this path, and there is an official Terraform provider that exercises it. The existing fix is in a sister helper used by both paths' upstream helper — the fix author plainly intended to defend both. - "AccessMode is
Nonein the subscription response, so the client should infer no-access" — The response still returns the fullRepositorypayload including description, default branch, language, fork status, counts, mirror metadata, OriginalURL, license, etc. (seeservices/convert/repository.go::innerToRepoline 189-259).AccessMode: 0does not gate the metadata fields, only thePermissions{Admin,Push,Pull}triple.
References
- GHSA-8fwc-qjw5-rvgp — the original advisory: https://github.com/go-gitea/gitea/security/advisories/GHSA-8fwc-qjw5-rvgp
- PR #36319 "clean watches when make a repository private and check permission when send release emails" (commit
8a98ac221) - PR #36959 "Require additional user confirmation for making repo private" (commit
943ff7523) — the later refactor that did not propagate the cleanup - Patched function:
services/repository/repository.go::MakeRepoPrivate(line 125, callsClearRepoWatchesline 157) - Unpatched sister:
services/repository/repository.go::updateRepository(line 240, calls onlyClearRepoStarsline 273) - API entry:
routers/api/v1/repo/repo.go::Edit→updateBasicProperties→repo_service.UpdateRepository→updateRepository - Test asymmetry:
services/repository/repository_test.go::TestMakeRepoPrivateClearsWatches(covered) vsTestUpdateRepositoryVisibilityChanged(does not assert watches cleared)
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐹Go | code.gitea.io/gitea | 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 code.gitea.io/gitea. 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 code.gitea.io/gitea to 1.27.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-q423-49rw-g9mh 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-q423-49rw-g9mh 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-q423-49rw-g9mh. 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-q423-49rw-g9mh in your dependencies?
O3 detects GHSA-q423-49rw-g9mh across Go dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.