Summary
The hookPostReceiveUpdateRepoByOptions function in Gitea's post-receive hook handler processes repo.private push options on empty repositories without checking the setting.Repository.ForcePrivate server policy. On instances where ForcePrivate is enabled, any authenticated user with repository admin permission (including regular repo creators) can make a repository public via git push -o repo.private=false, bypassing the site-level policy that every other visibility-change path enforces.
Root Cause
The ForcePrivate server policy is a site-level configuration (FORCE_PRIVATE = true in app.ini) intended to prevent non-site-admin users from making repositories public. Every visibility-change path in Gitea enforces this policy except the push option handler.
Affected function: hookPostReceiveUpdateRepoByOptions
File: routers/private/hook_post_receive.go (lines 143-188 in v1.27.3)
The function checks ctx.Repo.Permission.IsAdmin() (repo-level) at line 159
and repo.IsEmpty at line 166, but never checks setting.Repository.ForcePrivate.
Comparison with other paths that DO enforce the policy:
- API Edit: routers/api/v1/repo/repo.go line 696 checks ForcePrivate && !ctx.Doer.IsAdmin
- Web UI Settings: routers/web/repo/setting/setting.go line 1077 checks ForcePrivate && !ctx.Doer.IsAdmin
- Public Access: routers/web/repo/setting/public_access.go line 30 checks ForcePrivate
- Push-to-create service: services/repository/repository.go line 101 applies ForcePrivate at creation
Impact
When FORCE_PRIVATE = true is configured (a non-default, enterprise-oriented security setting), any authenticated user who can create repositories can bypass the policy on the push-to-create path. The repository is correctly created as private (the creation path enforces ForcePrivate), but the subsequent push option flips visibility to public. This makes the repository and its contents accessible to unauthenticated users, violating the organizational policy that all repositories must remain private.
The attacker must be an authenticated user with repository admin permission. Since all repository creators are automatically admins of their own repos, this is equivalent to any registered user. The attacker controls what content they push, so the confidentiality impact is limited to their own data becoming publicly visible in violation of policy. The integrity impact is the unauthorized modification of a security-relevant repository attribute.
ForcePrivate defaults to false, so only instances that have explicitly enabled this security policy are affected.
Attack Chain
- Exposure: An authenticated user pushes to an empty Gitea repository over HTTP or SSH. The post-receive hook fires for all pushes, including push-to-create.
- Control: The attacker includes
-o repo.private=false as a push option. This value is read as a boolean at hook_post_receive.go:144 and directly controls the isPrivate variable.
- Path: The push option is forwarded via the internal API to
hookPostReceiveUpdateRepoByOptions. The function loads the repo, checks repo-level admin permission (line 159), and checks repo.IsEmpty (line 166). For a first push to an empty repo, both checks pass.
- Guard: The missing check is
setting.Repository.ForcePrivate. Unlike the API edit endpoint (repo.go:696) and the web UI settings endpoint (setting.go:1077), the push option handler does not check this server-level policy before allowing a private-to-public transition.
- Primitive:
repo.IsPrivate = isPrivate.Value() at line 173, persisted via UpdateRepositoryColsNoAutoTime at line 175. The database record changes the repository's visibility.
- Result: The repository becomes publicly accessible. Anonymous users receive
AccessModeRead permission because GetIndividualUserRepoPermission at repo_permission.go:466 grants read access when repo.IsPrivate is false and the user is nil (unauthenticated).
Bypass Evidence
This finding is an incomplete fix of CVE-2026-58437 (GHSA-8p9h-49rc-qgxj). The CVE fix added a !repo.IsEmpty guard at line 166 to restrict push options to empty repositories only, preventing visibility manipulation on populated repos. However, the fix did not add ForcePrivate enforcement on the remaining allowed path (empty repos during push-to-create).
The push option handler at hook_post_receive.go contains zero references to setting.Repository.ForcePrivate, ForcePrivate, or ctx.Doer.IsAdmin. This was confirmed by searching the entire file. Every other code path that changes repository visibility (API edit, web UI settings, public access settings) enforces the ForcePrivate policy by checking setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin before allowing a private-to-public transition.
The strongest disproof attempted was whether applyPublicAccessPermission in the permission model provides a runtime backstop when ForcePrivate is enabled. Investigation showed it only controls per-unit anonymousAccessMode, not the primary perm.AccessMode which is set to AccessModeRead at repo_permission.go:466 when IsPrivate=false. No middleware-level ForcePrivate enforcement exists.
Affected Versions
- Ecosystem: Go
- Package:
code.gitea.io/gitea
- Confirmed affected range:
>= 1.27.0, <= 1.27.3
- Latest release checked: v1.27.3 (published 2026-08-29, Go module proxy)
- Fix status: not fixed
The lower bound is v1.27.0 because that release introduced the CVE-2026-58437 fix (!repo.IsEmpty guard) which made the ForcePrivate gap independently exploitable. Before v1.27.0, the broader CVE-2026-58437 vulnerability (push options on all repos) subsumed this specific policy bypass. The ForcePrivate gap was never addressed in any release through v1.27.3.
Suggested Fix
Add a ForcePrivate check in hookPostReceiveUpdateRepoByOptions before allowing a private-to-public transition. The check should mirror the pattern used in the API edit endpoint: when ForcePrivate is enabled and the push option would make a private repo public, deny the change unless the pusher is a site admin (ctx.Doer.IsAdmin). This ensures the push option handler enforces the same server-level policy as all other visibility-change paths.
As an interim mitigation, administrators can disable push-to-create functionality (ENABLE_PUSH_CREATE_USER = false and ENABLE_PUSH_CREATE_ORG = false in app.ini) to eliminate the empty-repo push path. Existing empty repositories created via the API or web UI remain exploitable through first push until the code is patched.
Reported by zx (GitHub: @manus-pi).
Summary
The
hookPostReceiveUpdateRepoByOptionsfunction in Gitea's post-receive hook handler processesrepo.privatepush options on empty repositories without checking thesetting.Repository.ForcePrivateserver policy. On instances where ForcePrivate is enabled, any authenticated user with repository admin permission (including regular repo creators) can make a repository public viagit push -o repo.private=false, bypassing the site-level policy that every other visibility-change path enforces.Root Cause
The ForcePrivate server policy is a site-level configuration (
FORCE_PRIVATE = trueinapp.ini) intended to prevent non-site-admin users from making repositories public. Every visibility-change path in Gitea enforces this policy except the push option handler.Impact
When
FORCE_PRIVATE = trueis configured (a non-default, enterprise-oriented security setting), any authenticated user who can create repositories can bypass the policy on the push-to-create path. The repository is correctly created as private (the creation path enforces ForcePrivate), but the subsequent push option flips visibility to public. This makes the repository and its contents accessible to unauthenticated users, violating the organizational policy that all repositories must remain private.The attacker must be an authenticated user with repository admin permission. Since all repository creators are automatically admins of their own repos, this is equivalent to any registered user. The attacker controls what content they push, so the confidentiality impact is limited to their own data becoming publicly visible in violation of policy. The integrity impact is the unauthorized modification of a security-relevant repository attribute.
ForcePrivate defaults to
false, so only instances that have explicitly enabled this security policy are affected.Attack Chain
-o repo.private=falseas a push option. This value is read as a boolean athook_post_receive.go:144and directly controls theisPrivatevariable.hookPostReceiveUpdateRepoByOptions. The function loads the repo, checks repo-level admin permission (line 159), and checksrepo.IsEmpty(line 166). For a first push to an empty repo, both checks pass.setting.Repository.ForcePrivate. Unlike the API edit endpoint (repo.go:696) and the web UI settings endpoint (setting.go:1077), the push option handler does not check this server-level policy before allowing a private-to-public transition.repo.IsPrivate = isPrivate.Value()at line 173, persisted viaUpdateRepositoryColsNoAutoTimeat line 175. The database record changes the repository's visibility.AccessModeReadpermission becauseGetIndividualUserRepoPermissionatrepo_permission.go:466grants read access whenrepo.IsPrivateis false and the user is nil (unauthenticated).Bypass Evidence
This finding is an incomplete fix of CVE-2026-58437 (GHSA-8p9h-49rc-qgxj). The CVE fix added a
!repo.IsEmptyguard at line 166 to restrict push options to empty repositories only, preventing visibility manipulation on populated repos. However, the fix did not addForcePrivateenforcement on the remaining allowed path (empty repos during push-to-create).The push option handler at
hook_post_receive.gocontains zero references tosetting.Repository.ForcePrivate,ForcePrivate, orctx.Doer.IsAdmin. This was confirmed by searching the entire file. Every other code path that changes repository visibility (API edit, web UI settings, public access settings) enforces the ForcePrivate policy by checkingsetting.Repository.ForcePrivate && !ctx.Doer.IsAdminbefore allowing a private-to-public transition.The strongest disproof attempted was whether
applyPublicAccessPermissionin the permission model provides a runtime backstop when ForcePrivate is enabled. Investigation showed it only controls per-unitanonymousAccessMode, not the primaryperm.AccessModewhich is set toAccessModeReadatrepo_permission.go:466whenIsPrivate=false. No middleware-level ForcePrivate enforcement exists.Affected Versions
code.gitea.io/gitea>= 1.27.0, <= 1.27.3The lower bound is v1.27.0 because that release introduced the CVE-2026-58437 fix (
!repo.IsEmptyguard) which made the ForcePrivate gap independently exploitable. Before v1.27.0, the broader CVE-2026-58437 vulnerability (push options on all repos) subsumed this specific policy bypass. The ForcePrivate gap was never addressed in any release through v1.27.3.Suggested Fix
Add a
ForcePrivatecheck inhookPostReceiveUpdateRepoByOptionsbefore allowing a private-to-public transition. The check should mirror the pattern used in the API edit endpoint: whenForcePrivateis enabled and the push option would make a private repo public, deny the change unless the pusher is a site admin (ctx.Doer.IsAdmin). This ensures the push option handler enforces the same server-level policy as all other visibility-change paths.As an interim mitigation, administrators can disable push-to-create functionality (
ENABLE_PUSH_CREATE_USER = falseandENABLE_PUSH_CREATE_ORG = falseinapp.ini) to eliminate the empty-repo push path. Existing empty repositories created via the API or web UI remain exploitable through first push until the code is patched.Reported by zx (GitHub: @manus-pi).