From c5eb8df8a25a8ad769e8183dec2ff23a94727cb5 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:12:00 -0500 Subject: [PATCH] feat(repo): enforce dot-prefixed repos as always-private system repos Repositories with names starting with "." are now treated as system repositories that are always private and cannot be made public. This is enforced at every code path: API create, web create, migrate, template create, push-to-create, API edit, web settings, and public access settings. On creation paths, privacy is silently forced. On edit paths, a clear error is returned. Co-Authored-By: Claude Opus 4.6 (1M context) --- models/repo/repo.go | 6 ++++++ options/locale/locale_en-US.json | 1 + routers/api/v1/repo/migrate.go | 4 ++-- routers/api/v1/repo/repo.go | 11 ++++++++++- routers/private/hook_post_receive.go | 13 +++++++++---- routers/web/repo/migrate.go | 2 +- routers/web/repo/repo.go | 4 ++-- routers/web/repo/setting/public_access.go | 12 ++++++++++-- routers/web/repo/setting/setting.go | 9 +++++++-- services/repository/repository.go | 4 ++++ 10 files changed, 52 insertions(+), 14 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 7814bb4876..590c62795b 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -303,6 +303,12 @@ func (repo *Repository) IsBeingCreated() bool { return repo.IsBeingMigrated() } +// IsSystemRepo returns true when the repository name starts with "." +// System repositories are always private and cannot be made public. +func (repo *Repository) IsSystemRepo() bool { + return strings.HasPrefix(repo.Name, ".") +} + // IsBroken indicates that repository is broken func (repo *Repository) IsBroken() bool { return repo.Status == RepositoryBroken diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..2eb1b70af2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2496,6 +2496,7 @@ "repo.settings.visibility.success": "Repository visibility changed.", "repo.settings.visibility.error": "An error occurred while trying to change the repo visibility.", "repo.settings.visibility.fork_error": "Can't change the visibility of a forked repo.", + "repo.settings.visibility.system_repo_private": "System repositories (dot-prefixed names) are always private and cannot be made public.", "repo.settings.archive.button": "Archive Repo", "repo.settings.archive.header": "Archive This Repo", "repo.settings.archive.text": "Archiving the repo will make it entirely read-only. It will be hidden from the dashboard. Nobody (not even you!) will be able to make new commits, or open any issues or pull requests.", diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 7431493a3f..6e09af98e9 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -139,7 +139,7 @@ func Migrate(ctx *context.APIContext) { CloneAddr: remoteAddr, RepoName: form.RepoName, Description: form.Description, - Private: form.Private || setting.Repository.ForcePrivate, + Private: form.Private || setting.Repository.ForcePrivate || strings.HasPrefix(form.RepoName, "."), Mirror: form.Mirror, LFS: form.LFS, LFSEndpoint: form.LFSEndpoint, @@ -174,7 +174,7 @@ func Migrate(ctx *context.APIContext) { Description: opts.Description, OriginalURL: form.CloneAddr, GitServiceType: gitServiceType, - IsPrivate: opts.Private || setting.Repository.ForcePrivate, + IsPrivate: opts.Private || setting.Repository.ForcePrivate || strings.HasPrefix(opts.RepoName, "."), IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, }, false) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 8b0dc7c863..641a6f447d 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -253,6 +253,9 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre return } + // System repos (dot-prefixed names) are always private. + isPrivate := opt.Private || setting.Repository.ForcePrivate || strings.HasPrefix(opt.Name, ".") + repo, err := repo_service.CreateRepository(ctx, ctx.Doer, owner, repo_service.CreateRepoOptions{ Name: opt.Name, Description: opt.Description, @@ -260,7 +263,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre Gitignores: opt.Gitignores, License: opt.License, Readme: opt.Readme, - IsPrivate: opt.Private || setting.Repository.ForcePrivate, + IsPrivate: isPrivate, AutoInit: opt.AutoInit, DefaultBranch: opt.DefaultBranch, TrustModel: repo_model.ToTrustModel(opt.TrustModel), @@ -700,6 +703,12 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err } visibilityChanged = repo.IsPrivate != *opts.Private + // System repos (dot-prefixed) cannot be made public, regardless of user role. + if visibilityChanged && !*opts.Private && repo.IsSystemRepo() { + err := errors.New("system repositories (dot-prefixed) cannot be made public") + ctx.APIError(http.StatusForbidden, err) + return err + } // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public if visibilityChanged && setting.Repository.ForcePrivate && !*opts.Private && !ctx.Doer.IsAdmin { err := errors.New("cannot change private repository to public") diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 3d070a18ab..903b2c7fac 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -210,10 +210,15 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag // These options should only be used for "push-to-create" if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() { - // TODO: it needs to do more work - repo.IsPrivate = isPrivate.Value() - if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to change visibility"}) + // System repos (dot-prefixed) are always private — ignore attempts to make them public. + if !isPrivate.Value() && repo.IsSystemRepo() { + log.Warn("Ignoring push option to make system repo %s public", repo.FullName()) + } else { + // TODO: it needs to do more work + repo.IsPrivate = isPrivate.Value() + if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to change visibility"}) + } } } if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() { diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index bb6f1e6b7e..e0fe917243 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -210,7 +210,7 @@ func MigratePost(ctx *context.Context) { CloneAddr: remoteAddr, RepoName: form.RepoName, Description: form.Description, - Private: form.Private || setting.Repository.ForcePrivate, + Private: form.Private || setting.Repository.ForcePrivate || strings.HasPrefix(form.RepoName, "."), Mirror: form.Mirror, LFS: form.LFS, LFSEndpoint: form.LFSEndpoint, diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index c7813feae2..538074250b 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -243,7 +243,7 @@ func CreatePost(ctx *context.Context) { opts := repo_service.GenerateRepoOptions{ Name: form.RepoName, Description: form.Description, - Private: form.Private || setting.Repository.ForcePrivate, + Private: form.Private || setting.Repository.ForcePrivate || strings.HasPrefix(form.RepoName, "."), GitContent: form.GitContent, Topics: form.Topics, GitHooks: form.GitHooks, @@ -282,7 +282,7 @@ func CreatePost(ctx *context.Context) { IssueLabels: form.IssueLabels, License: form.License, Readme: form.Readme, - IsPrivate: form.Private || setting.Repository.ForcePrivate, + IsPrivate: form.Private || setting.Repository.ForcePrivate || strings.HasPrefix(form.RepoName, "."), DefaultBranch: form.DefaultBranch, AutoInit: form.AutoInit, IsTemplate: form.Template, diff --git a/routers/web/repo/setting/public_access.go b/routers/web/repo/setting/public_access.go index 368d34294a..1f9180383c 100644 --- a/routers/web/repo/setting/public_access.go +++ b/routers/web/repo/setting/public_access.go @@ -127,14 +127,22 @@ func repoUnitPublicAccesses(ctx *context.Context) []*repoUnitPublicAccess { func PublicAccess(ctx *context.Context) { ctx.Data["PageIsSettingsPublicAccess"] = true ctx.Data["RepoUnitPublicAccesses"] = repoUnitPublicAccesses(ctx) - ctx.Data["GlobalForcePrivate"] = setting.Repository.ForcePrivate - if setting.Repository.ForcePrivate { + ctx.Data["GlobalForcePrivate"] = setting.Repository.ForcePrivate || ctx.Repo.Repository.IsSystemRepo() + if ctx.Repo.Repository.IsSystemRepo() { + ctx.Flash.Error(ctx.Tr("repo.settings.visibility.system_repo_private"), true) + } else if setting.Repository.ForcePrivate { ctx.Flash.Error(ctx.Tr("form.repository_force_private"), true) } ctx.HTML(http.StatusOK, tplRepoSettingsPublicAccess) } func PublicAccessPost(ctx *context.Context) { + // System repos (dot-prefixed) cannot have public access on any unit. + if ctx.Repo.Repository.IsSystemRepo() { + ctx.Flash.Error(ctx.Tr("repo.settings.visibility.system_repo_private"), true) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/public_access") + return + } accesses := repoUnitPublicAccesses(ctx) for _, ua := range accesses { formVal := ctx.FormString(ua.FormKey) diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 816fd91cd8..c94499ad6e 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -54,7 +54,7 @@ const ( func SettingsCtxData(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.options") ctx.Data["PageIsSettingsOptions"] = true - ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate || ctx.Repo.Repository.IsSystemRepo() ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush @@ -103,7 +103,7 @@ func Settings(ctx *context.Context) { // SettingsPost response for changes of a repository func SettingsPost(ctx *context.Context) { - ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate + ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate || ctx.Repo.Repository.IsSystemRepo() ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush @@ -1008,6 +1008,11 @@ func handleSettingsPostVisibility(ctx *context.Context) { private := ctx.FormOptionalBool("private").ValueOrDefault(true) // default to true for privacy & safety + // System repos (dot-prefixed) cannot be made public, regardless of user role. + if !private && repo.IsSystemRepo() { + ctx.JSONError(ctx.Tr("repo.settings.visibility.system_repo_private")) + return + } // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public if !private && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin { ctx.JSONError(ctx.Tr("form.repository_force_private")) diff --git a/services/repository/repository.go b/services/repository/repository.go index 2ac95ffe3a..d285480442 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -123,6 +123,10 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili } func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository, private bool) (err error) { + // System repos (dot-prefixed) are always private and cannot be made public. + if !private && repo.IsSystemRepo() { + return errors.New("system repositories (dot-prefixed) cannot be made public") + } return db.WithTx(ctx, func(ctx context.Context) error { repo.IsPrivate = private if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { -- 2.52.0