mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-18 15:37:48 +00:00
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>
382 lines
15 KiB
Go
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")
|
|
})
|
|
}
|