gitforge/tests/integration/git_push_test.go
Mathieu Fenniak c1c02f7111 fix: don't cache write permission on first pushed git reference (#13370)
Forgejo's git pre-receive hook validates whether the authenticated user can write to the target branch during a push operation.  A caching performance optimization in the hook caused the pre-receive hook to validate only the first branch reference in the push.  While typically a push operation has the same rights for all branches on a repository, some exceptions exist which could allow a user to push to a branch, pass the validation, and then bypass checks on additional references due to the cache.  The cache has been removed, forcing all references to be validated for code write permissions.

Thanks to Gitea for identifying this issue in https://github.com/go-gitea/gitea/pull/38103/changes/f25811942cea35233299efdbdfeec40663d4807a. This effort has been re-engineered for Forgejo, consistent with Forgejo's AI Agreement.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13370
Reviewed-by: Beowulf <beowulf@beocode.eu>
2026-07-09 06:07:05 +02:00

382 lines
15 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/url"
"testing"
"forgejo.org/models/db"
git_model "forgejo.org/models/git"
issues_model "forgejo.org/models/issues"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
repo_module "forgejo.org/modules/repository"
pull_service "forgejo.org/services/pull"
repo_service "forgejo.org/services/repository"
"forgejo.org/tests"
"forgejo.org/tests/forgery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func forEachObjectFormat(t *testing.T, f func(t *testing.T, objectFormat git.ObjectFormat)) {
for _, objectFormat := range []git.ObjectFormat{git.Sha256ObjectFormat, git.Sha1ObjectFormat} {
if !git.SupportHashSha256 && objectFormat == git.Sha256ObjectFormat {
continue
}
t.Run(objectFormat.Name(), func(t *testing.T) {
f(t, objectFormat)
})
}
}
func TestGitPush(t *testing.T) {
onApplicationRun(t, testGitPush)
}
func testGitPush(t *testing.T, u *url.URL) {
forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
t.Run("Push branches at once", func(t *testing.T) {
runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
for i := range 10 {
branchName := fmt.Sprintf("branch-%d", i)
pushed = append(pushed, branchName)
doGitCreateBranch(gitPath, branchName)(t)
}
pushed = append(pushed, "master")
doGitPushTestRepository(gitPath, "origin", "--all")(t)
return pushed, deleted
})
})
t.Run("Push branches exists", func(t *testing.T) {
runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
for i := range 10 {
branchName := fmt.Sprintf("branch-%d", i)
if i < 5 {
pushed = append(pushed, branchName)
}
doGitCreateBranch(gitPath, branchName)(t)
}
// only push master and the first 5 branches
pushed = append(pushed, "master")
args := append([]string{"origin"}, pushed...)
doGitPushTestRepository(gitPath, args...)(t)
pushed = pushed[:0]
// do some changes for the first 5 branches created above
for i := range 5 {
branchName := fmt.Sprintf("branch-%d", i)
pushed = append(pushed, branchName)
doGitAddSomeCommits(gitPath, branchName)(t)
}
for i := 5; i < 10; i++ {
pushed = append(pushed, fmt.Sprintf("branch-%d", i))
}
pushed = append(pushed, "master")
// push all, so that master are not changed
doGitPushTestRepository(gitPath, "origin", "--all")(t)
return pushed, deleted
})
})
t.Run("Push branches one by one", func(t *testing.T) {
runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
for i := range 10 {
branchName := fmt.Sprintf("branch-%d", i)
doGitCreateBranch(gitPath, branchName)(t)
doGitPushTestRepository(gitPath, "origin", branchName)(t)
pushed = append(pushed, branchName)
}
return pushed, deleted
})
})
t.Run("Delete branches", func(t *testing.T) {
runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete
pushed = append(pushed, "master")
for i := range 10 {
branchName := fmt.Sprintf("branch-%d", i)
pushed = append(pushed, branchName)
doGitCreateBranch(gitPath, branchName)(t)
}
doGitPushTestRepository(gitPath, "origin", "--all")(t)
for i := range 10 {
branchName := fmt.Sprintf("branch-%d", i)
doGitPushTestRepository(gitPath, "origin", "--delete", branchName)(t)
deleted = append(deleted, branchName)
}
return pushed, deleted
})
})
t.Run("Push to deleted branch", func(t *testing.T) {
runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete
pushed = append(pushed, "master")
doGitCreateBranch(gitPath, "branch-1")(t)
doGitPushTestRepository(gitPath, "origin", "branch-1")(t)
pushed = append(pushed, "branch-1")
// delete and restore
doGitPushTestRepository(gitPath, "origin", "--delete", "branch-1")(t)
doGitPushTestRepository(gitPath, "origin", "branch-1")(t)
return pushed, deleted
})
})
})
}
func runTestGitPush(t *testing.T, u *url.URL, objectFormat git.ObjectFormat, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
defer tests.PrintCurrentTest(t, 1)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
Name: "repo-to-push",
Description: "test git push",
AutoInit: false,
DefaultBranch: "main",
IsPrivate: false,
ObjectFormatName: objectFormat.Name(),
})
require.NoError(t, err)
require.NotEmpty(t, repo)
gitPath := t.TempDir()
doGitInitTestRepository(gitPath, objectFormat)(t)
oldPath := u.Path
oldUser := u.User
defer func() {
u.Path = oldPath
u.User = oldUser
}()
u.Path = repo.FullName() + ".git"
u.User = url.UserPassword(user.LowerName, userPassword)
doGitAddRemote(gitPath, "origin", u)(t)
gitRepo, err := git.OpenRepository(git.DefaultContext, gitPath)
require.NoError(t, err)
defer gitRepo.Close()
pushedBranches, deletedBranches := gitOperation(t, gitPath)
dbBranches := make([]*git_model.Branch, 0)
require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", repo.ID).Find(&dbBranches))
assert.Lenf(t, dbBranches, len(pushedBranches), "mismatched number of branches in db")
dbBranchesMap := make(map[string]*git_model.Branch, len(dbBranches))
for _, branch := range dbBranches {
dbBranchesMap[branch.Name] = branch
}
deletedBranchesMap := make(map[string]bool, len(deletedBranches))
for _, branchName := range deletedBranches {
deletedBranchesMap[branchName] = true
}
for _, branchName := range pushedBranches {
branch, ok := dbBranchesMap[branchName]
deleted := deletedBranchesMap[branchName]
assert.True(t, ok, "branch %s not found in database", branchName)
assert.Equal(t, deleted, branch.IsDeleted, "IsDeleted of %s is %v, but it's expected to be %v", branchName, branch.IsDeleted, deleted)
commitID, err := gitRepo.GetBranchCommitID(branchName)
require.NoError(t, err)
assert.Equal(t, commitID, branch.CommitID)
}
require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, repo.ID, repo_service.DeleteRepositoryOpts{}))
}
func TestOptionsGitPush(t *testing.T) {
onApplicationRun(t, testOptionsGitPush)
}
func testOptionsGitPush(t *testing.T, u *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
Name: "repo-to-push",
Description: "test git push",
AutoInit: false,
DefaultBranch: "main",
IsPrivate: false,
ObjectFormatName: objectFormat.Name(),
})
require.NoError(t, err)
require.NotEmpty(t, repo)
gitPath := t.TempDir()
doGitInitTestRepository(gitPath, objectFormat)(t)
u.Path = repo.FullName() + ".git"
u.User = url.UserPassword(user.LowerName, userPassword)
doGitAddRemote(gitPath, "origin", u)(t)
t.Run("Unknown push options are silently ignored", func(t *testing.T) {
branchName := "branch0"
doGitCreateBranch(gitPath, branchName)(t)
doGitPushTestRepository(gitPath, "origin", branchName, "-o", "uknownoption=randomvalue", "-o", "repo.private=true")(t)
repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
require.NoError(t, err)
require.True(t, repo.IsPrivate)
require.False(t, repo.IsTemplate)
})
t.Run("Owner sets private & template to true via push options", func(t *testing.T) {
branchName := "branch1"
doGitCreateBranch(gitPath, branchName)(t)
doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=true", "-o", "repo.template=true")(t)
repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
require.NoError(t, err)
require.True(t, repo.IsPrivate)
require.True(t, repo.IsTemplate)
})
t.Run("Owner sets private & template to false via push options", func(t *testing.T) {
branchName := "branch2"
doGitCreateBranch(gitPath, branchName)(t)
doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=false", "-o", "repo.template=false")(t)
repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
require.NoError(t, err)
require.False(t, repo.IsPrivate)
require.False(t, repo.IsTemplate)
})
// create a collaborator user
collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
u.User = url.UserPassword(collaborator.LowerName, userPassword)
doGitAddRemote(gitPath, "collaborator", u)(t)
t.Run("User without write access is not allowed to push", func(t *testing.T) {
branchName := "branch3"
doGitCreateBranch(gitPath, branchName)(t)
stderr := doGitPushTestRepositoryFail(t, gitPath, "collaborator", branchName)
assert.Contains(t, stderr, `remote: Forgejo: User 'user5' is not allowed to push to branch 'branch3' in 'user2/repo-to-push'.`)
assert.Contains(t, stderr, `remote: If you instead wanted to create a pull request to the branch 'branch3', please use:`)
assert.Contains(t, stderr, `remote: git push origin HEAD:refs/for/branch3/choose-a-descriptor`)
assert.Contains(t, stderr, `remote: You might want to replace 'origin' with the name of your Git remote if it is different from origin. You can freely choose the descriptor to set it to a topic.`)
assert.Contains(t, stderr, `remote: You can learn about creating pull requests with AGit in the docs: https://forgejo.org/docs/latest/user/agit-support/`)
})
// give write access to the collaborator
require.NoError(t, repo_module.AddCollaborator(db.DefaultContext, repo, collaborator))
t.Run("Collaborator with write access is allowed to push", func(t *testing.T) {
branchName := "branch4"
doGitCreateBranch(gitPath, branchName)(t)
doGitPushTestRepository(gitPath, "collaborator", branchName)(t)
})
t.Run("Collaborator with write access fails to change private & template via push options", func(t *testing.T) {
branchName := "branch5"
doGitCreateBranch(gitPath, branchName)(t)
stderr := doGitPushTestRepositoryFail(t, gitPath, "collaborator", branchName, "-o", "repo.private=true", "-o", "repo.template=true")
assert.Contains(t, stderr, "Forgejo: options validation failed: permission denied for changing repo settings")
repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
require.NoError(t, err)
require.False(t, repo.IsPrivate)
require.False(t, repo.IsTemplate)
})
require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, repo.ID, repo_service.DeleteRepositoryOpts{}))
})
}
func TestGitPushAllowMaintainerEditRestrictedHead(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
baseRepoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
// Create a base repository as a target for the pull request
baseRepo := forgery.CreateRepository(t, baseRepoOwner, &forgery.CreateRepositoryOptions{DefaultBranch: "master"})
baseRepoPath := t.TempDir()
doGitInitTestRepository(baseRepoPath, git.Sha1ObjectFormat)(t)
u.Path = baseRepo.FullName() + ".git"
u.User = url.UserPassword(baseRepoOwner.LowerName, userPassword)
doGitAddRemote(baseRepoPath, "origin", u)(t)
doGitPushTestRepository(baseRepoPath, "origin", baseRepo.DefaultBranch)(t)
// Fork the base repo
forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
forkRepo, err := repo_service.ForkRepositoryAndUpdates(t.Context(), forkUser, forkUser, repo_service.ForkRepoOptions{
BaseRepo: baseRepo,
Name: "repo-pr-update",
Description: "desc",
})
require.NoError(t, err)
forkRepoPath := t.TempDir()
u.Path = forkRepo.FullName() + ".git"
u.User = url.UserPassword(forkUser.LowerName, userPassword)
doGitClone(forkRepoPath, u)(t)
// Make a modification in the fork repo
branchName := "my-branch-for-pr"
doGitCreateBranch(forkRepoPath, branchName)(t)
doGitAddSomeCommits(forkRepoPath, branchName)(t)
doGitPushTestRepository(forkRepoPath, "origin", branchName)(t)
// Create a pull request in the base repo to incorporate the fork's modification
pullIssue := &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "Test Pull Request from Fork",
PosterID: forkUser.ID,
Poster: forkUser,
IsPull: true,
}
pullRequest := &issues_model.PullRequest{
HeadRepo: forkRepo,
HeadRepoID: forkRepo.ID,
HeadBranch: branchName,
BaseRepo: baseRepo,
BaseRepoID: baseRepo.ID,
BaseBranch: baseRepo.DefaultBranch,
Type: issues_model.PullRequestGitea,
AllowMaintainerEdit: true,
}
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
require.NoError(t, err)
// The existence of the pull request allows maintainers of the base repo (baseRepoOwner) to write to the fork
// repo, but *only* to the branch for the pull request. Set up for editing as the baseRepoOwner...
u.Path = forkRepo.FullName() + ".git"
u.User = url.UserPassword(baseRepoOwner.LowerName, userPassword)
doGitAddRemote(baseRepoPath, "fork", u)(t)
doGitFetch(baseRepoPath, "fork")(t)
doGitCheckoutBranch(baseRepoPath, branchName)(t)
// Test writing to the PR branch, should succeed:
doGitAddSomeCommits(baseRepoPath, branchName)(t)
doGitPushTestRepository(baseRepoPath, "fork", branchName)(t)
// We're allowed to write to the PR branch, but not to another branch:
doGitCreateBranch(baseRepoPath, "another-branch")(t)
doGitAddSomeCommits(baseRepoPath, "another-branch")(t)
doGitPushTestRepositoryFail(t, baseRepoPath, "fork", "another-branch")
// Verify that each branch being pushed is checked independently -- pushing to a branch we're permitted to, and
// then a branch that we're not, does not allow the push:
doGitAddSomeCommits(baseRepoPath, branchName)(t) // Ensure we have new commits ready to push
doGitAddSomeCommits(baseRepoPath, "another-branch")(t) // Ensure we have new commits ready to push
doGitPushTestRepositoryFail(t, baseRepoPath, "fork", branchName, "another-branch")
})
}