From 9608cc212d77e5019f35091a6973bac4290f967d Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 13 Jun 2026 06:02:02 +0200 Subject: [PATCH] fix: allow git clone of private repos with anonymous code access (#38074) Fixes #38062. --------- Co-authored-by: wxiaoguang --- routers/web/repo/githttp.go | 43 ++++++++++++------------ tests/integration/git_smart_http_test.go | 34 +++++++++++++++++++ tests/sqlite.ini.tmpl | 1 - 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index c1c2ed5e86..4ae2955f6d 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler { // httpBase does the common work for git http services, // including early response, authentication, repository lookup and permission check. func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { - reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") - if ctx.FormString("go-get") == "1" { context.EarlyResponseForGoGetMeta(ctx) return nil @@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { isWiki := false unitType := unit.TypeCode - - if strings.HasSuffix(reponame, ".wiki") { + repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") + if strings.HasSuffix(repoName, ".wiki") { isWiki = true unitType = unit.TypeWiki - reponame = reponame[:len(reponame)-5] + repoName = repoName[:len(repoName)-5] } owner := ctx.ContextUser @@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { } repoExist := true - repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) if err != nil { if !repo_model.IsErrRepoNotExist(err) { ctx.ServerError("GetRepositoryByName", err) return nil } - if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { + if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil { context.RedirectToRepo(ctx.Base, redirectRepoID) return nil } @@ -127,23 +125,26 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - // Only public pull don't need auth. - isPublicPull := repoExist && !repo.IsPrivate && isPull - askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict - - // don't allow anonymous pulls if organization is not public - if isPublicPull { - if err := repo.LoadOwner(ctx); err != nil { - ctx.ServerError("LoadOwner", err) - return nil + // Only public pulls don't need auth: repo must exist, not require-sign-in + canAnonymousPull := false + if isPull && repoExist && !setting.Service.RequireSignInViewStrict { + // allow anonymous pulls if owner is public and repo is public (not private) + if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate { + canAnonymousPull = true + } + // then check "public anonymous access" permission + if !canAnonymousPull && ctx.Doer == nil { + anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil) + if err != nil { + ctx.ServerError("GetDoerRepoPermission", err) + return nil + } + canAnonymousPull = anonPerm.CanAccess(accessMode, unitType) } - - askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) } // check access - if askAuth { - // rely on the results of Contexter + if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth if !ctx.IsSigned { // TODO: support digit auth - which would be Authorization header with digit if setting.OAuth2.Enabled { @@ -229,7 +230,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) + repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName) if err != nil { log.Error("pushCreateRepo: %v", err) ctx.Status(http.StatusNotFound) diff --git a/tests/integration/git_smart_http_test.go b/tests/integration/git_smart_http_test.go index dfbda5c701..df8bc1caeb 100644 --- a/tests/integration/git_smart_http_test.go +++ b/tests/integration/git_smart_http_test.go @@ -10,7 +10,9 @@ import ( "testing" auth_model "gitea.dev/models/auth" + "gitea.dev/models/perm" repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" "gitea.dev/models/unittest" "gitea.dev/modules/setting" "gitea.dev/modules/test" @@ -26,6 +28,8 @@ func TestGitSmartHTTP(t *testing.T) { testGitSmartHTTPTokenScopes(t) testRenamedRepoRedirect(t) testGitArchiveRemote(t, u) + t.Run("AnonymousAccess-Repo", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, false) }) + t.Run("AnonymousAccess-Wiki", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, true) }) }) } @@ -144,3 +148,33 @@ func testGitArchiveRemote(t *testing.T, u *url.URL) { t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test")) t.Run("list compression options", doGitRemoteArchive(u.String(), "--list")) } + +// testGitSmartHTTPPrivateRepoAnonymousAccess tests that a private repo with +// anonymous code access enabled can be cloned without credentials. +func testGitSmartHTTPPrivateRepoAnonymousAccess(t *testing.T, isWiki bool) { + // repo1 (ID=1) belongs to user2 and is public by default in fixtures + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerName: "user2", Name: "repo1"}) + unitType := util.Iif(isWiki, unit.TypeWiki, unit.TypeCode) + repoLink := "/" + repo.FullName() + util.Iif(isWiki, ".wiki", "") + gitPullPath := repoLink + "/info/refs?service=git-upload-pack" + gitPushPath := repoLink + "/info/refs?service=git-receive-pack" + + // make the repo private + require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: repo.ID, IsPrivate: true}, "is_private")) + + // without anonymous access: anonymous pull must require auth + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized) + + // enable anonymous read access on the unit + require.NoError(t, repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, AnonymousAccessMode: perm.AccessModeRead})) + + // with anonymous code access: anonymous pull must succeed without credentials + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusOK) + + // push (receive-pack) must still require auth even with anonymous code access + MakeRequest(t, NewRequest(t, "GET", gitPushPath), http.StatusUnauthorized) + + // RequireSignInViewStrict must override anonymous access + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized) +} diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index a12735e06d..95a1df283f 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -5,7 +5,6 @@ RUN_MODE = prod [database] DB_TYPE = sqlite3 PATH = gitea-test.db -SQLITE_JOURNAL_MODE = WAL [indexer] REPO_INDEXER_ENABLED = true