From 8de44d1995e73ec139ede5626466af1fce8c571a Mon Sep 17 00:00:00 2001 From: zeripath Date: Thu, 16 Sep 2021 14:34:54 +0100 Subject: [PATCH] Clean-up HookPreReceive and restore functionality for pushing non-standard refs (#16705) * Clean-up HookPreReceive and restore functionality for pushing non-standard refs There was an inadvertent breaking change in #15629 meaning that notes refs and other git extension refs will be automatically rejected. Further following #14295 and #15629 the pre-recieve hook code is untenably long and too complex. This PR refactors the hook code and removes the incorrect forced rejection of non-standard refs. Fix #16688 Signed-off-by: Andrew Thornton --- modules/web/route.go | 10 + routers/private/default_branch.go | 75 +++ routers/private/hook.go | 777 --------------------------- routers/private/hook_post_receive.go | 201 +++++++ routers/private/hook_pre_receive.go | 471 ++++++++++++++++ routers/private/hook_proc_receive.go | 34 ++ routers/private/hook_verification.go | 122 +++++ routers/private/internal.go | 6 +- routers/private/internal_repo.go | 84 +++ 9 files changed, 1000 insertions(+), 780 deletions(-) create mode 100644 routers/private/default_branch.go delete mode 100644 routers/private/hook.go create mode 100644 routers/private/hook_post_receive.go create mode 100644 routers/private/hook_pre_receive.go create mode 100644 routers/private/hook_proc_receive.go create mode 100644 routers/private/hook_verification.go create mode 100644 routers/private/internal_repo.go diff --git a/modules/web/route.go b/modules/web/route.go index 3c6513da626ce..9b08510264bfe 100644 --- a/modules/web/route.go +++ b/modules/web/route.go @@ -31,6 +31,7 @@ func Wrap(handlers ...interface{}) http.HandlerFunc { func(ctx *context.Context) goctx.CancelFunc, func(*context.APIContext), func(*context.PrivateContext), + func(*context.PrivateContext) goctx.CancelFunc, func(http.Handler) http.Handler: default: panic(fmt.Sprintf("Unsupported handler type: %#v", t)) @@ -59,6 +60,15 @@ func Wrap(handlers ...interface{}) http.HandlerFunc { if ctx.Written() { return } + case func(*context.PrivateContext) goctx.CancelFunc: + ctx := context.GetPrivateContext(req) + cancel := t(ctx) + if cancel != nil { + defer cancel() + } + if ctx.Written() { + return + } case func(ctx *context.Context): ctx := context.GetContext(req) t(ctx) diff --git a/routers/private/default_branch.go b/routers/private/default_branch.go new file mode 100644 index 0000000000000..ec6adc4805020 --- /dev/null +++ b/routers/private/default_branch.go @@ -0,0 +1,75 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" +) + +// ________ _____ .__ __ +// \______ \ _____/ ____\____ __ __| |_/ |_ +// | | \_/ __ \ __\\__ \ | | \ |\ __\ +// | ` \ ___/| | / __ \| | / |_| | +// /_______ /\___ >__| (____ /____/|____/__| +// \/ \/ \/ +// __________ .__ +// \______ \____________ ____ ____ | |__ +// | | _/\_ __ \__ \ / \_/ ___\| | \ +// | | \ | | \// __ \| | \ \___| Y \ +// |______ / |__| (____ /___| /\___ >___| / +// \/ \/ \/ \/ \/ + +// SetDefaultBranch updates the default branch +func SetDefaultBranch(ctx *gitea_context.PrivateContext) { + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + branch := ctx.Params(":branch") + repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) + if err != nil { + log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + if repo.OwnerName == "" { + repo.OwnerName = ownerName + } + + repo.DefaultBranch = branch + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Failed to get git repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + if err := gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if !git.IsErrUnsupportedVersion(err) { + gitRepo.Close() + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + gitRepo.Close() + + if err := repo.UpdateDefaultBranch(); err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + ctx.PlainText(http.StatusOK, []byte("success")) +} diff --git a/routers/private/hook.go b/routers/private/hook.go deleted file mode 100644 index d928dc421ce1f..0000000000000 --- a/routers/private/hook.go +++ /dev/null @@ -1,777 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. -package private - -import ( - "bufio" - "context" - "fmt" - "io" - "net/http" - "os" - "strings" - - "code.gitea.io/gitea/models" - gitea_context "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/private" - repo_module "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/services/agit" - pull_service "code.gitea.io/gitea/services/pull" - repo_service "code.gitea.io/gitea/services/repository" -) - -func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - log.Error("Unable to create os.Pipe for %s", repo.Path) - return err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - - // This is safe as force pushes are already forbidden - err = git.NewCommand("rev-list", oldCommitID+"..."+newCommitID). - RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path, - stdoutWriter, nil, nil, - func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) - if err != nil { - log.Error("%v", err) - cancel() - } - _ = stdoutReader.Close() - return err - }) - if err != nil && !isErrUnverifiedCommit(err) { - log.Error("Unable to check commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) - } - return err -} - -func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error { - scanner := bufio.NewScanner(input) - for scanner.Scan() { - line := scanner.Text() - err := readAndVerifyCommit(line, repo, env) - if err != nil { - log.Error("%v", err) - return err - } - } - return scanner.Err() -} - -func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - log.Error("Unable to create pipe for %s: %v", repo.Path, err) - return err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - hash := git.MustIDFromString(sha) - - return git.NewCommand("cat-file", "commit", sha). - RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path, - stdoutWriter, nil, nil, - func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - commit, err := git.CommitFromReader(repo, hash, stdoutReader) - if err != nil { - return err - } - verification := models.ParseCommitWithSignature(commit) - if !verification.Verified { - cancel() - return &errUnverifiedCommit{ - commit.ID.String(), - } - } - return nil - }) -} - -type errUnverifiedCommit struct { - sha string -} - -func (e *errUnverifiedCommit) Error() string { - return fmt.Sprintf("Unverified commit: %s", e.sha) -} - -func isErrUnverifiedCommit(err error) bool { - _, ok := err.(*errUnverifiedCommit) - return ok -} - -// HookPreReceive checks whether a individual commit is acceptable -func HookPreReceive(ctx *gitea_context.PrivateContext) { - opts := web.GetForm(ctx).(*private.HookOptions) - ownerName := ctx.Params(":owner") - repoName := ctx.Params(":repo") - repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) - if err != nil { - log.Error("Unable to get repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err.Error(), - }) - return - } - repo.OwnerName = ownerName - gitRepo, err := git.OpenRepository(repo.RepoPath()) - if err != nil { - log.Error("Unable to get git repository for: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err.Error(), - }) - return - } - defer gitRepo.Close() - - // Generate git environment for checking commits - env := os.Environ() - if opts.GitAlternativeObjectDirectories != "" { - env = append(env, - private.GitAlternativeObjectDirectories+"="+opts.GitAlternativeObjectDirectories) - } - if opts.GitObjectDirectory != "" { - env = append(env, - private.GitObjectDirectory+"="+opts.GitObjectDirectory) - } - if opts.GitQuarantinePath != "" { - env = append(env, - private.GitQuarantinePath+"="+opts.GitQuarantinePath) - } - - if git.SupportProcReceive { - pusher, err := models.GetUserByID(opts.UserID) - if err != nil { - log.Error("models.GetUserByID:%v", err) - ctx.Error(http.StatusInternalServerError, "") - return - } - - perm, err := models.GetUserRepoPermission(repo, pusher) - if err != nil { - log.Error("models.GetUserRepoPermission:%v", err) - ctx.Error(http.StatusInternalServerError, "") - return - } - - canCreatePullRequest := perm.CanRead(models.UnitTypePullRequests) - - for _, refFullName := range opts.RefFullNames { - // if user want update other refs (branch or tag), - // should check code write permission because - // this check was delayed. - if !strings.HasPrefix(refFullName, git.PullRequestPrefix) { - if !perm.CanWrite(models.UnitTypeCode) { - ctx.JSON(http.StatusForbidden, map[string]interface{}{ - "err": "User permission denied.", - }) - return - } - - break - } else if repo.IsEmpty { - ctx.JSON(http.StatusForbidden, map[string]interface{}{ - "err": "Can't create pull request for an empty repository.", - }) - return - } else if !canCreatePullRequest { - ctx.JSON(http.StatusForbidden, map[string]interface{}{ - "err": "User permission denied.", - }) - return - } else if opts.IsWiki { - // TODO: maybe can do it ... - ctx.JSON(http.StatusForbidden, map[string]interface{}{ - "err": "not support send pull request to wiki.", - }) - return - } - } - } - - protectedTags, err := repo.GetProtectedTags() - if err != nil { - log.Error("Unable to get protected tags for %-v Error: %v", repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err.Error(), - }) - return - } - - // Iterate across the provided old commit IDs - for i := range opts.OldCommitIDs { - oldCommitID := opts.OldCommitIDs[i] - newCommitID := opts.NewCommitIDs[i] - refFullName := opts.RefFullNames[i] - - if strings.HasPrefix(refFullName, git.BranchPrefix) { - branchName := strings.TrimPrefix(refFullName, git.BranchPrefix) - if branchName == repo.DefaultBranch && newCommitID == git.EmptySHA { - log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is the default branch and cannot be deleted", branchName), - }) - return - } - - protectBranch, err := models.GetProtectedBranchBy(repo.ID, branchName) - if err != nil { - log.Error("Unable to get protected branch: %s in %-v Error: %v", branchName, repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err.Error(), - }) - return - } - - // Allow pushes to non-protected branches - if protectBranch == nil || !protectBranch.IsProtected() { - continue - } - - // This ref is a protected branch. - // - // First of all we need to enforce absolutely: - // - // 1. Detect and prevent deletion of the branch - if newCommitID == git.EmptySHA { - log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is protected from deletion", branchName), - }) - return - } - - // 2. Disallow force pushes to protected branches - if git.EmptySHA != oldCommitID { - output, err := git.NewCommand("rev-list", "--max-count=1", oldCommitID, "^"+newCommitID).RunInDirWithEnv(repo.RepoPath(), env) - if err != nil { - log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, newCommitID, repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Fail to detect force push: %v", err), - }) - return - } else if len(output) > 0 { - log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is protected from force push", branchName), - }) - return - - } - } - - // 3. Enforce require signed commits - if protectBranch.RequireSignedCommits { - err := verifyCommits(oldCommitID, newCommitID, gitRepo, env) - if err != nil { - if !isErrUnverifiedCommit(err) { - log.Error("Unable to check commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to check commits from %s to %s: %v", oldCommitID, newCommitID, err), - }) - return - } - unverifiedCommit := err.(*errUnverifiedCommit).sha - log.Warn("Forbidden: Branch: %s in %-v is protected from unverified commit %s", branchName, repo, unverifiedCommit) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is protected from unverified commit %s", branchName, unverifiedCommit), - }) - return - } - } - - // Now there are several tests which can be overridden: - // - // 4. Check protected file patterns - this is overridable from the UI - changedProtectedfiles := false - protectedFilePath := "" - - globs := protectBranch.GetProtectedFilePatterns() - if len(globs) > 0 { - _, err := pull_service.CheckFileProtection(oldCommitID, newCommitID, globs, 1, env, gitRepo) - if err != nil { - if !models.IsErrFilePathProtected(err) { - log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), - }) - return - } - - changedProtectedfiles = true - protectedFilePath = err.(models.ErrFilePathProtected).Path - } - } - - // 5. Check if the doer is allowed to push - canPush := false - if opts.IsDeployKey { - canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) - } else { - canPush = !changedProtectedfiles && protectBranch.CanUserPush(opts.UserID) - } - - // 6. If we're not allowed to push directly - if !canPush { - // Is this is a merge from the UI/API? - if opts.PullRequestID == 0 { - // 6a. If we're not merging from the UI/API then there are two ways we got here: - // - // We are changing a protected file and we're not allowed to do that - if changedProtectedfiles { - log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), - }) - return - } - - // Allow commits that only touch unprotected files - globs := protectBranch.GetUnprotectedFilePatterns() - if len(globs) > 0 { - unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(oldCommitID, newCommitID, globs, env, gitRepo) - if err != nil { - log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), - }) - return - } - if unprotectedFilesOnly { - // Commit only touches unprotected files, this is allowed - continue - } - } - - // Or we're simply not able to push to this protected branch - log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", opts.UserID, branchName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), - }) - return - } - // 6b. Merge (from UI or API) - - // Get the PR, user and permissions for the user in the repository - pr, err := models.GetPullRequestByID(opts.PullRequestID) - if err != nil { - log.Error("Unable to get PullRequest %d Error: %v", opts.PullRequestID, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get PullRequest %d Error: %v", opts.PullRequestID, err), - }) - return - } - user, err := models.GetUserByID(opts.UserID) - if err != nil { - log.Error("Unable to get User id %d Error: %v", opts.UserID, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get User id %d Error: %v", opts.UserID, err), - }) - return - } - perm, err := models.GetUserRepoPermission(repo, user) - if err != nil { - log.Error("Unable to get Repo permission of repo %s/%s of User %s", repo.OwnerName, repo.Name, user.Name, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get Repo permission of repo %s/%s of User %s: %v", repo.OwnerName, repo.Name, user.Name, err), - }) - return - } - - // Now check if the user is allowed to merge PRs for this repository - allowedMerge, err := pull_service.IsUserAllowedToMerge(pr, perm, user) - if err != nil { - log.Error("Error calculating if allowed to merge: %v", err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Error calculating if allowed to merge: %v", err), - }) - return - } - - if !allowedMerge { - log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v and is not allowed to merge pr #%d", opts.UserID, branchName, repo, pr.Index) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), - }) - return - } - - // If we're an admin for the repository we can ignore status checks, reviews and override protected files - if perm.IsAdmin() { - continue - } - - // Now if we're not an admin - we can't overwrite protected files so fail now - if changedProtectedfiles { - log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), - }) - return - } - - // Check all status checks and reviews are ok - if err := pull_service.CheckPRReadyToMerge(pr, true); err != nil { - if models.IsErrNotAllowedToMerge(err) { - log.Warn("Forbidden: User %d is not allowed push to protected branch %s in %-v and pr #%d is not ready to be merged: %s", opts.UserID, branchName, repo, pr.Index, err.Error()) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("Not allowed to push to protected branch %s and pr #%d is not ready to be merged: %s", branchName, opts.PullRequestID, err.Error()), - }) - return - } - log.Error("Unable to check if mergable: protected branch %s in %-v and pr #%d. Error: %v", opts.UserID, branchName, repo, pr.Index, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get status of pull request %d. Error: %v", opts.PullRequestID, err), - }) - return - } - } - } else if strings.HasPrefix(refFullName, git.TagPrefix) { - tagName := strings.TrimPrefix(refFullName, git.TagPrefix) - - isAllowed, err := models.IsUserAllowedToControlTag(protectedTags, tagName, opts.UserID) - if err != nil { - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err.Error(), - }) - return - } - if !isAllowed { - log.Warn("Forbidden: Tag %s in %-v is protected", tagName, repo) - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("Tag %s is protected", tagName), - }) - return - } - } else if git.SupportProcReceive && strings.HasPrefix(refFullName, git.PullRequestPrefix) { - baseBranchName := opts.RefFullNames[i][len(git.PullRequestPrefix):] - - baseBranchExist := false - if gitRepo.IsBranchExist(baseBranchName) { - baseBranchExist = true - } - - if !baseBranchExist { - for p, v := range baseBranchName { - if v == '/' && gitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 { - baseBranchExist = true - break - } - } - } - - if !baseBranchExist { - ctx.JSON(http.StatusForbidden, private.Response{ - Err: fmt.Sprintf("Unexpected ref: %s", refFullName), - }) - return - } - } else { - log.Error("Unexpected ref: %s", refFullName) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unexpected ref: %s", refFullName), - }) - return - } - } - - ctx.PlainText(http.StatusOK, []byte("ok")) -} - -// HookPostReceive updates services and users -func HookPostReceive(ctx *gitea_context.PrivateContext) { - opts := web.GetForm(ctx).(*private.HookOptions) - ownerName := ctx.Params(":owner") - repoName := ctx.Params(":repo") - - var repo *models.Repository - updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs)) - wasEmpty := false - - for i := range opts.OldCommitIDs { - refFullName := opts.RefFullNames[i] - - // Only trigger activity updates for changes to branches or - // tags. Updates to other refs (eg, refs/notes, refs/changes, - // or other less-standard refs spaces are ignored since there - // may be a very large number of them). - if strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) { - if repo == nil { - var err error - repo, err = models.GetRepositoryByOwnerAndName(ownerName, repoName) - if err != nil { - log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - if repo.OwnerName == "" { - repo.OwnerName = ownerName - } - wasEmpty = repo.IsEmpty - } - - option := repo_module.PushUpdateOptions{ - RefFullName: refFullName, - OldCommitID: opts.OldCommitIDs[i], - NewCommitID: opts.NewCommitIDs[i], - PusherID: opts.UserID, - PusherName: opts.UserName, - RepoUserName: ownerName, - RepoName: repoName, - } - updates = append(updates, &option) - if repo.IsEmpty && option.IsBranch() && (option.BranchName() == "master" || option.BranchName() == "main") { - // put the master/main branch first - copy(updates[1:], updates) - updates[0] = &option - } - } - } - - if repo != nil && len(updates) > 0 { - if err := repo_service.PushUpdates(updates); err != nil { - log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates)) - for i, update := range updates { - log.Error("Failed to Update: %s/%s Update: %d/%d: Branch: %s", ownerName, repoName, i, len(updates), update.BranchName()) - } - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - } - - // Push Options - if repo != nil && len(opts.GitPushOptions) > 0 { - repo.IsPrivate = opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate, repo.IsPrivate) - repo.IsTemplate = opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate, repo.IsTemplate) - if err := models.UpdateRepositoryCols(repo, "is_private", "is_template"); err != nil { - log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), - }) - } - } - - results := make([]private.HookPostReceiveBranchResult, 0, len(opts.OldCommitIDs)) - - // We have to reload the repo in case its state is changed above - repo = nil - var baseRepo *models.Repository - - for i := range opts.OldCommitIDs { - refFullName := opts.RefFullNames[i] - newCommitID := opts.NewCommitIDs[i] - - branch := git.RefEndName(opts.RefFullNames[i]) - - if newCommitID != git.EmptySHA && strings.HasPrefix(refFullName, git.BranchPrefix) { - if repo == nil { - var err error - repo, err = models.GetRepositoryByOwnerAndName(ownerName, repoName) - if err != nil { - log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), - RepoWasEmpty: wasEmpty, - }) - return - } - if repo.OwnerName == "" { - repo.OwnerName = ownerName - } - - if !repo.AllowsPulls() { - // We can stop there's no need to go any further - ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ - RepoWasEmpty: wasEmpty, - }) - return - } - baseRepo = repo - - if repo.IsFork { - if err := repo.GetBaseRepo(); err != nil { - log.Error("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err), - RepoWasEmpty: wasEmpty, - }) - return - } - baseRepo = repo.BaseRepo - } - } - - if !repo.IsFork && branch == baseRepo.DefaultBranch { - results = append(results, private.HookPostReceiveBranchResult{}) - continue - } - - pr, err := models.GetUnmergedPullRequest(repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, models.PullRequestFlowGithub) - if err != nil && !models.IsErrPullRequestNotExist(err) { - log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err) - ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ - Err: fmt.Sprintf( - "Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err), - RepoWasEmpty: wasEmpty, - }) - return - } - - if pr == nil { - if repo.IsFork { - branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch) - } - results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), - Create: true, - Branch: branch, - URL: fmt.Sprintf("%s/compare/%s...%s", baseRepo.HTMLURL(), util.PathEscapeSegments(baseRepo.DefaultBranch), util.PathEscapeSegments(branch)), - }) - } else { - results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), - Create: false, - Branch: branch, - URL: fmt.Sprintf("%s/pulls/%d", baseRepo.HTMLURL(), pr.Index), - }) - } - } - } - ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ - Results: results, - RepoWasEmpty: wasEmpty, - }) -} - -// HookProcReceive proc-receive hook -func HookProcReceive(ctx *gitea_context.PrivateContext) { - opts := web.GetForm(ctx).(*private.HookOptions) - if !git.SupportProcReceive { - ctx.Status(http.StatusNotFound) - return - } - - cancel := loadRepositoryAndGitRepoByParams(ctx) - if ctx.Written() { - return - } - defer cancel() - - results := agit.ProcRecive(ctx, opts) - if ctx.Written() { - return - } - - ctx.JSON(http.StatusOK, private.HookProcReceiveResult{ - Results: results, - }) -} - -// SetDefaultBranch updates the default branch -func SetDefaultBranch(ctx *gitea_context.PrivateContext) { - ownerName := ctx.Params(":owner") - repoName := ctx.Params(":repo") - branch := ctx.Params(":branch") - repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) - if err != nil { - log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - if repo.OwnerName == "" { - repo.OwnerName = ownerName - } - - repo.DefaultBranch = branch - gitRepo, err := git.OpenRepository(repo.RepoPath()) - if err != nil { - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Failed to get git repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - if err := gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { - if !git.IsErrUnsupportedVersion(err) { - gitRepo.Close() - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - } - gitRepo.Close() - - if err := repo.UpdateDefaultBranch(); err != nil { - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return - } - ctx.PlainText(http.StatusOK, []byte("success")) -} - -func loadRepositoryAndGitRepoByParams(ctx *gitea_context.PrivateContext) context.CancelFunc { - ownerName := ctx.Params(":owner") - repoName := ctx.Params(":repo") - - repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) - if err != nil { - log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ - "Err": fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return nil - } - if repo.OwnerName == "" { - repo.OwnerName = ownerName - } - - gitRepo, err := git.OpenRepository(repo.RepoPath()) - if err != nil { - log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) - ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ - "Err": fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), - }) - return nil - } - - ctx.Repo = &gitea_context.Repository{ - Repository: repo, - GitRepo: gitRepo, - } - - // We opened it, we should close it - cancel := func() { - // If it's been set to nil then assume someone else has closed it. - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - } - } - - return cancel -} diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go new file mode 100644 index 0000000000000..4dbe74a9aa08d --- /dev/null +++ b/routers/private/hook_post_receive.go @@ -0,0 +1,201 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + repo_service "code.gitea.io/gitea/services/repository" +) + +// HookPostReceive updates services and users +func HookPostReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + + // We don't rely on RepoAssignment here because: + // a) we don't need the git repo in this function + // b) our update function will likely change the repository in the db so we will need to refresh it + // c) we don't always need the repo + + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + + // defer getting the repository at this point - as we should only retrieve it if we're going to call update + var repo *models.Repository + + updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs)) + wasEmpty := false + + for i := range opts.OldCommitIDs { + refFullName := opts.RefFullNames[i] + + // Only trigger activity updates for changes to branches or + // tags. Updates to other refs (eg, refs/notes, refs/changes, + // or other less-standard refs spaces are ignored since there + // may be a very large number of them). + if strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) { + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return + } + wasEmpty = repo.IsEmpty + } + + option := repo_module.PushUpdateOptions{ + RefFullName: refFullName, + OldCommitID: opts.OldCommitIDs[i], + NewCommitID: opts.NewCommitIDs[i], + PusherID: opts.UserID, + PusherName: opts.UserName, + RepoUserName: ownerName, + RepoName: repoName, + } + updates = append(updates, &option) + if repo.IsEmpty && option.IsBranch() && (option.BranchName() == "master" || option.BranchName() == "main") { + // put the master/main branch first + copy(updates[1:], updates) + updates[0] = &option + } + } + } + + if repo != nil && len(updates) > 0 { + if err := repo_service.PushUpdates(updates); err != nil { + log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates)) + for i, update := range updates { + log.Error("Failed to Update: %s/%s Update: %d/%d: Branch: %s", ownerName, repoName, i, len(updates), update.BranchName()) + } + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + + // Handle Push Options + if len(opts.GitPushOptions) > 0 { + // load the repository + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return + } + wasEmpty = repo.IsEmpty + } + + repo.IsPrivate = opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate, repo.IsPrivate) + repo.IsTemplate = opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate, repo.IsTemplate) + if err := models.UpdateRepositoryCols(repo, "is_private", "is_template"); err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + } + } + + results := make([]private.HookPostReceiveBranchResult, 0, len(opts.OldCommitIDs)) + + // We have to reload the repo in case its state is changed above + repo = nil + var baseRepo *models.Repository + + // Now handle the pull request notification trailers + for i := range opts.OldCommitIDs { + refFullName := opts.RefFullNames[i] + newCommitID := opts.NewCommitIDs[i] + + branch := git.RefEndName(opts.RefFullNames[i]) + + // If we've pushed a branch (and not deleted it) + if newCommitID != git.EmptySHA && strings.HasPrefix(refFullName, git.BranchPrefix) { + + // First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + + if !repo.AllowsPulls() { + // We can stop there's no need to go any further + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ + RepoWasEmpty: wasEmpty, + }) + return + } + baseRepo = repo + + if repo.IsFork { + if err := repo.GetBaseRepo(); err != nil { + log.Error("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err), + RepoWasEmpty: wasEmpty, + }) + return + } + baseRepo = repo.BaseRepo + } + } + + // If our branch is the default branch of an unforked repo - there's no PR to create or refer to + if !repo.IsFork && branch == baseRepo.DefaultBranch { + results = append(results, private.HookPostReceiveBranchResult{}) + continue + } + + pr, err := models.GetUnmergedPullRequest(repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, models.PullRequestFlowGithub) + if err != nil && !models.IsErrPullRequestNotExist(err) { + log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf( + "Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err), + RepoWasEmpty: wasEmpty, + }) + return + } + + if pr == nil { + if repo.IsFork { + branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch) + } + results = append(results, private.HookPostReceiveBranchResult{ + Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), + Create: true, + Branch: branch, + URL: fmt.Sprintf("%s/compare/%s...%s", baseRepo.HTMLURL(), util.PathEscapeSegments(baseRepo.DefaultBranch), util.PathEscapeSegments(branch)), + }) + } else { + results = append(results, private.HookPostReceiveBranchResult{ + Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), + Create: false, + Branch: branch, + URL: fmt.Sprintf("%s/pulls/%d", baseRepo.HTMLURL(), pr.Index), + }) + } + } + } + ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ + Results: results, + RepoWasEmpty: wasEmpty, + }) +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go new file mode 100644 index 0000000000000..3e4995376cef8 --- /dev/null +++ b/routers/private/hook_pre_receive.go @@ -0,0 +1,471 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "fmt" + "net/http" + "os" + "strings" + + "code.gitea.io/gitea/models" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/web" + pull_service "code.gitea.io/gitea/services/pull" +) + +type preReceiveContext struct { + *gitea_context.PrivateContext + user *models.User + perm models.Permission + + canCreatePullRequest bool + checkedCanCreatePullRequest bool + + canWriteCode bool + checkedCanWriteCode bool + + protectedTags []*models.ProtectedTag + gotProtectedTags bool + + env []string + + opts *private.HookOptions +} + +// User gets or loads User +func (ctx *preReceiveContext) User() *models.User { + if ctx.user == nil { + ctx.user, ctx.perm = loadUserAndPermission(ctx.PrivateContext, ctx.opts.UserID) + } + return ctx.user +} + +// Perm gets or loads Perm +func (ctx *preReceiveContext) Perm() *models.Permission { + if ctx.user == nil { + ctx.user, ctx.perm = loadUserAndPermission(ctx.PrivateContext, ctx.opts.UserID) + } + return &ctx.perm +} + +// CanWriteCode returns true if can write code +func (ctx *preReceiveContext) CanWriteCode() bool { + if !ctx.checkedCanWriteCode { + ctx.canWriteCode = ctx.Perm().CanWrite(models.UnitTypeCode) + ctx.checkedCanWriteCode = true + } + return ctx.canWriteCode +} + +// AssertCanWriteCode returns true if can write code +func (ctx *preReceiveContext) AssertCanWriteCode() bool { + if !ctx.CanWriteCode() { + if ctx.Written() { + return false + } + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "User permission denied.", + }) + return false + } + return true +} + +// CanCreatePullRequest returns true if can create pull requests +func (ctx *preReceiveContext) CanCreatePullRequest() bool { + if !ctx.checkedCanCreatePullRequest { + ctx.canCreatePullRequest = ctx.Perm().CanRead(models.UnitTypePullRequests) + ctx.checkedCanCreatePullRequest = true + } + return ctx.canCreatePullRequest +} + +// AssertCanCreatePullRequest returns true if can create pull requests +func (ctx *preReceiveContext) AssertCreatePullRequest() bool { + if !ctx.CanCreatePullRequest() { + if ctx.Written() { + return false + } + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "User permission denied.", + }) + return false + } + return true +} + +// HookPreReceive checks whether a individual commit is acceptable +func HookPreReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + + ourCtx := &preReceiveContext{ + PrivateContext: ctx, + env: generateGitEnv(opts), // Generate git environment for checking commits + opts: opts, + } + + // Iterate across the provided old commit IDs + for i := range opts.OldCommitIDs { + oldCommitID := opts.OldCommitIDs[i] + newCommitID := opts.NewCommitIDs[i] + refFullName := opts.RefFullNames[i] + + switch { + case strings.HasPrefix(refFullName, git.BranchPrefix): + preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName) + case strings.HasPrefix(refFullName, git.TagPrefix): + preReceiveTag(ourCtx, oldCommitID, newCommitID, refFullName) + case git.SupportProcReceive && strings.HasPrefix(refFullName, git.PullRequestPrefix): + preReceivePullRequest(ourCtx, oldCommitID, newCommitID, refFullName) + default: + ourCtx.AssertCanWriteCode() + } + if ctx.Written() { + return + } + } + + ctx.PlainText(http.StatusOK, []byte("ok")) +} + +func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID, refFullName string) { + if !ctx.AssertCanWriteCode() { + return + } + + repo := ctx.Repo.Repository + gitRepo := ctx.Repo.GitRepo + branchName := strings.TrimPrefix(refFullName, git.BranchPrefix) + + if branchName == repo.DefaultBranch && newCommitID == git.EmptySHA { + log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is the default branch and cannot be deleted", branchName), + }) + return + } + + protectBranch, err := models.GetProtectedBranchBy(repo.ID, branchName) + if err != nil { + log.Error("Unable to get protected branch: %s in %-v Error: %v", branchName, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + // Allow pushes to non-protected branches + if protectBranch == nil || !protectBranch.IsProtected() { + return + } + + // This ref is a protected branch. + // + // First of all we need to enforce absolutely: + // + // 1. Detect and prevent deletion of the branch + if newCommitID == git.EmptySHA { + log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is protected from deletion", branchName), + }) + return + } + + // 2. Disallow force pushes to protected branches + if git.EmptySHA != oldCommitID { + output, err := git.NewCommand("rev-list", "--max-count=1", oldCommitID, "^"+newCommitID).RunInDirWithEnv(repo.RepoPath(), ctx.env) + if err != nil { + log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Fail to detect force push: %v", err), + }) + return + } else if len(output) > 0 { + log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is protected from force push", branchName), + }) + return + + } + } + + // 3. Enforce require signed commits + if protectBranch.RequireSignedCommits { + err := verifyCommits(oldCommitID, newCommitID, gitRepo, ctx.env) + if err != nil { + if !isErrUnverifiedCommit(err) { + log.Error("Unable to check commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + unverifiedCommit := err.(*errUnverifiedCommit).sha + log.Warn("Forbidden: Branch: %s in %-v is protected from unverified commit %s", branchName, repo, unverifiedCommit) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is protected from unverified commit %s", branchName, unverifiedCommit), + }) + return + } + } + + // Now there are several tests which can be overridden: + // + // 4. Check protected file patterns - this is overridable from the UI + changedProtectedfiles := false + protectedFilePath := "" + + globs := protectBranch.GetProtectedFilePatterns() + if len(globs) > 0 { + _, err := pull_service.CheckFileProtection(oldCommitID, newCommitID, globs, 1, ctx.env, gitRepo) + if err != nil { + if !models.IsErrFilePathProtected(err) { + log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + + } + + changedProtectedfiles = true + protectedFilePath = err.(models.ErrFilePathProtected).Path + } + } + + // 5. Check if the doer is allowed to push + canPush := false + if ctx.opts.IsDeployKey { + canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) + } else { + canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx.opts.UserID) + } + + // 6. If we're not allowed to push directly + if !canPush { + // Is this is a merge from the UI/API? + if ctx.opts.PullRequestID == 0 { + // 6a. If we're not merging from the UI/API then there are two ways we got here: + // + // We are changing a protected file and we're not allowed to do that + if changedProtectedfiles { + log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), + }) + return + } + + // Allow commits that only touch unprotected files + globs := protectBranch.GetUnprotectedFilePatterns() + if len(globs) > 0 { + unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(oldCommitID, newCommitID, globs, ctx.env, gitRepo) + if err != nil { + log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err), + }) + return + } + if unprotectedFilesOnly { + // Commit only touches unprotected files, this is allowed + return + } + } + + // Or we're simply not able to push to this protected branch + log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), + }) + return + } + // 6b. Merge (from UI or API) + + // Get the PR, user and permissions for the user in the repository + pr, err := models.GetPullRequestByID(ctx.opts.PullRequestID) + if err != nil { + log.Error("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err), + }) + return + } + + // Now check if the user is allowed to merge PRs for this repository + // Note: we can use ctx.perm and ctx.user directly as they will have been loaded above + allowedMerge, err := pull_service.IsUserAllowedToMerge(pr, ctx.perm, ctx.user) + if err != nil { + log.Error("Error calculating if allowed to merge: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Error calculating if allowed to merge: %v", err), + }) + return + } + + if !allowedMerge { + log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v and is not allowed to merge pr #%d", ctx.opts.UserID, branchName, repo, pr.Index) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Not allowed to push to protected branch %s", branchName), + }) + return + } + + // If we're an admin for the repository we can ignore status checks, reviews and override protected files + if ctx.perm.IsAdmin() { + return + } + + // Now if we're not an admin - we can't overwrite protected files so fail now + if changedProtectedfiles { + log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath), + }) + return + } + + // Check all status checks and reviews are ok + if err := pull_service.CheckPRReadyToMerge(pr, true); err != nil { + if models.IsErrNotAllowedToMerge(err) { + log.Warn("Forbidden: User %d is not allowed push to protected branch %s in %-v and pr #%d is not ready to be merged: %s", ctx.opts.UserID, branchName, repo, pr.Index, err.Error()) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Not allowed to push to protected branch %s and pr #%d is not ready to be merged: %s", branchName, ctx.opts.PullRequestID, err.Error()), + }) + return + } + log.Error("Unable to check if mergable: protected branch %s in %-v and pr #%d. Error: %v", ctx.opts.UserID, branchName, repo, pr.Index, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get status of pull request %d. Error: %v", ctx.opts.PullRequestID, err), + }) + return + } + } +} + +func preReceiveTag(ctx *preReceiveContext, oldCommitID, newCommitID, refFullName string) { + if !ctx.AssertCanWriteCode() { + return + } + + tagName := strings.TrimPrefix(refFullName, git.TagPrefix) + + if !ctx.gotProtectedTags { + var err error + ctx.protectedTags, err = ctx.Repo.Repository.GetProtectedTags() + if err != nil { + log.Error("Unable to get protected tags for %-v Error: %v", ctx.Repo.Repository, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + ctx.gotProtectedTags = true + } + + isAllowed, err := models.IsUserAllowedToControlTag(ctx.protectedTags, tagName, ctx.opts.UserID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + if !isAllowed { + log.Warn("Forbidden: Tag %s in %-v is protected", tagName, ctx.Repo.Repository) + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Tag %s is protected", tagName), + }) + return + } +} + +func preReceivePullRequest(ctx *preReceiveContext, oldCommitID, newCommitID, refFullName string) { + if !ctx.AssertCreatePullRequest() { + return + } + + if ctx.Repo.Repository.IsEmpty { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "Can't create pull request for an empty repository.", + }) + return + } + + if ctx.opts.IsWiki { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "err": "Pull requests are not suppported on the wiki.", + }) + return + } + + baseBranchName := refFullName[len(git.PullRequestPrefix):] + + baseBranchExist := false + if ctx.Repo.GitRepo.IsBranchExist(baseBranchName) { + baseBranchExist = true + } + + if !baseBranchExist { + for p, v := range baseBranchName { + if v == '/' && ctx.Repo.GitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 { + baseBranchExist = true + break + } + } + } + + if !baseBranchExist { + ctx.JSON(http.StatusForbidden, private.Response{ + Err: fmt.Sprintf("Unexpected ref: %s", refFullName), + }) + return + } +} + +func generateGitEnv(opts *private.HookOptions) (env []string) { + env = os.Environ() + if opts.GitAlternativeObjectDirectories != "" { + env = append(env, + private.GitAlternativeObjectDirectories+"="+opts.GitAlternativeObjectDirectories) + } + if opts.GitObjectDirectory != "" { + env = append(env, + private.GitObjectDirectory+"="+opts.GitObjectDirectory) + } + if opts.GitQuarantinePath != "" { + env = append(env, + private.GitQuarantinePath+"="+opts.GitQuarantinePath) + } + return env +} + +func loadUserAndPermission(ctx *gitea_context.PrivateContext, id int64) (user *models.User, perm models.Permission) { + user, err := models.GetUserByID(id) + if err != nil { + log.Error("Unable to get User id %d Error: %v", id, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get User id %d Error: %v", id, err), + }) + return + } + + perm, err = models.GetUserRepoPermission(ctx.Repo.Repository, user) + if err != nil { + log.Error("Unable to get Repo permission of repo %s/%s of User %s", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err), + }) + return + } + + return +} diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go new file mode 100644 index 0000000000000..e427a55c56161 --- /dev/null +++ b/routers/private/hook_proc_receive.go @@ -0,0 +1,34 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "net/http" + + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/agit" +) + +// HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present +func HookProcReceive(ctx *gitea_context.PrivateContext) { + opts := web.GetForm(ctx).(*private.HookOptions) + if !git.SupportProcReceive { + ctx.Status(http.StatusNotFound) + return + } + + results := agit.ProcRecive(ctx, opts) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusOK, private.HookProcReceiveResult{ + Results: results, + }) +} diff --git a/routers/private/hook_verification.go b/routers/private/hook_verification.go new file mode 100644 index 0000000000000..8c7492ea6859c --- /dev/null +++ b/routers/private/hook_verification.go @@ -0,0 +1,122 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// _________ .__ __ +// \_ ___ \ ____ _____ _____ |__|/ |_ +// / \ \/ / _ \ / \ / \| \ __\ +// \ \___( <_> ) Y Y \ Y Y \ || | +// \______ /\____/|__|_| /__|_| /__||__| +// \/ \/ \/ +// ____ ____ .__ _____.__ __ .__ +// \ \ / /___________|__|/ ____\__| ____ _____ _/ |_|__| ____ ____ +// \ Y // __ \_ __ \ \ __\| |/ ___\\__ \\ __\ |/ _ \ / \ +// \ /\ ___/| | \/ || | | \ \___ / __ \| | | ( <_> ) | \ +// \___/ \___ >__| |__||__| |__|\___ >____ /__| |__|\____/|___| / +// \/ \/ \/ \/ +// +// This file contains commit verification functions for refs passed across in hooks + +func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + log.Error("Unable to create os.Pipe for %s", repo.Path) + return err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + + // This is safe as force pushes are already forbidden + err = git.NewCommand("rev-list", oldCommitID+"..."+newCommitID). + RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path, + stdoutWriter, nil, nil, + func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) + if err != nil { + log.Error("%v", err) + cancel() + } + _ = stdoutReader.Close() + return err + }) + if err != nil && !isErrUnverifiedCommit(err) { + log.Error("Unable to check commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) + } + return err +} + +func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error { + scanner := bufio.NewScanner(input) + for scanner.Scan() { + line := scanner.Text() + err := readAndVerifyCommit(line, repo, env) + if err != nil { + log.Error("%v", err) + return err + } + } + return scanner.Err() +} + +func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + log.Error("Unable to create pipe for %s: %v", repo.Path, err) + return err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + hash := git.MustIDFromString(sha) + + return git.NewCommand("cat-file", "commit", sha). + RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path, + stdoutWriter, nil, nil, + func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + commit, err := git.CommitFromReader(repo, hash, stdoutReader) + if err != nil { + return err + } + verification := models.ParseCommitWithSignature(commit) + if !verification.Verified { + cancel() + return &errUnverifiedCommit{ + commit.ID.String(), + } + } + return nil + }) +} + +type errUnverifiedCommit struct { + sha string +} + +func (e *errUnverifiedCommit) Error() string { + return fmt.Sprintf("Unverified commit: %s", e.sha) +} + +func isErrUnverifiedCommit(err error) bool { + _, ok := err.(*errUnverifiedCommit) + return ok +} diff --git a/routers/private/internal.go b/routers/private/internal.go index 155e8c036ba7b..183ab5e98ac28 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -56,10 +56,10 @@ func Routes() *web.Route { r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent) r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo) r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog) - r.Post("/hook/pre-receive/{owner}/{repo}", bind(private.HookOptions{}), HookPreReceive) + r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive) r.Post("/hook/post-receive/{owner}/{repo}", bind(private.HookOptions{}), HookPostReceive) - r.Post("/hook/proc-receive/{owner}/{repo}", bind(private.HookOptions{}), HookProcReceive) - r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", SetDefaultBranch) + r.Post("/hook/proc-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookProcReceive) + r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", RepoAssignment, SetDefaultBranch) r.Get("/serv/none/{keyid}", ServNoCommand) r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand) r.Post("/manager/shutdown", Shutdown) diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go new file mode 100644 index 0000000000000..60daa1dbeaae2 --- /dev/null +++ b/routers/private/internal_repo.go @@ -0,0 +1,84 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package private includes all internal routes. The package name internal is ideal but Golang is not allowed, so we use private as package name instead. +package private + +import ( + "context" + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// __________ +// \______ \ ____ ______ ____ +// | _// __ \\____ \ / _ \ +// | | \ ___/| |_> > <_> ) +// |____|_ /\___ > __/ \____/ +// \/ \/|__| +// _____ .__ __ +// / _ \ ______ _____|__| ____ ____ _____ ____ _____/ |_ +// / /_\ \ / ___// ___/ |/ ___\ / \ / \_/ __ \ / \ __\ +// / | \\___ \ \___ \| / /_/ > | \ Y Y \ ___/| | \ | +// \____|__ /____ >____ >__\___ /|___| /__|_| /\___ >___| /__| +// \/ \/ \/ /_____/ \/ \/ \/ \/ + +// This file contains common functions relating to setting the Repository for the +// internal routes + +// RepoAssignment assigns the repository and gitrepository to the private context +func RepoAssignment(ctx *gitea_context.PrivateContext) context.CancelFunc { + ownerName := ctx.Params(":owner") + repoName := ctx.Params(":repo") + + repo := loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + // Error handled in loadRepository + return nil + } + + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + + ctx.Repo = &gitea_context.Repository{ + Repository: repo, + GitRepo: gitRepo, + } + + // We opened it, we should close it + cancel := func() { + // If it's been set to nil then assume someone else has closed it. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + } + + return cancel +} + +func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *models.Repository { + repo, err := models.GetRepositoryByOwnerAndName(ownerName, repoName) + if err != nil { + log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "Err": fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return nil + } + if repo.OwnerName == "" { + repo.OwnerName = ownerName + } + return repo +}