Summary
Gitea: GHSA-8fwc-qjw5-rvgp ClearRepoWatches fix not applied to API EditRepo path — sister code path retains stale watches on public->private
Advisory details
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.
References
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-55496
Cloudreve: Information Exposure in `GET /api/v4/user/search`: `SearchActive` omits the active-status predicate, leaking inactive/banned account emails
- HIGHCVE-2026-75912
CodeWhale: Argument Injection in `git_blame` Tool Allows Arbitrary File Read Without Approval
- HIGHCVE-2026-75915
CodeWhale: js_execution leaks parent environment to model context via missing env scrub
- HIGHCVE-2026-75859
CodeWhale: Project config `instructions` override enables arbitrary file read into AI system prompt via cloned repository
- HIGHCVE-2026-72804
SiYuan: Graph endpoints omit the publish-password tier: anonymous readers receive block-level content of password-protected documents
- MEDIUMCVE-2026-61842
Grav: Twig sandbox config exfiltration via grav.offsetGet + dump filter (CVE-2026-44738 bypass)