Allow to set protected file patterns that can not be changed under no conditions (#10806)

Co-Authored-By: zeripath <art27@cantab.net>
This commit is contained in:
Lauris BH 2020-03-27 00:26:34 +02:00 committed by GitHub
parent 52cfd2743c
commit bbd910ed1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 202 additions and 23 deletions

View file

@ -7,6 +7,7 @@ package models
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
@ -15,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/gobwas/glob"
"github.com/unknwon/com" "github.com/unknwon/com"
) )
@ -47,6 +49,7 @@ type ProtectedBranch struct {
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"` BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"` DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"` RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
ProtectedFilePatterns string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"` CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
@ -190,6 +193,22 @@ func (protectBranch *ProtectedBranch) MergeBlockedByRejectedReview(pr *PullReque
return rejectExist return rejectExist
} }
// GetProtectedFilePatterns parses a semicolon separated list of protected file patterns and returns a glob.Glob slice
func (protectBranch *ProtectedBranch) GetProtectedFilePatterns() []glob.Glob {
extarr := make([]glob.Glob, 0, 10)
for _, expr := range strings.Split(strings.ToLower(protectBranch.ProtectedFilePatterns), ";") {
expr = strings.TrimSpace(expr)
if expr != "" {
if g, err := glob.Compile(expr, '.', '/'); err != nil {
log.Info("Invalid glob expresion '%s' (skipped): %v", expr, err)
} else {
extarr = append(extarr, g)
}
}
}
return extarr
}
// GetProtectedBranchByRepoID getting protected branch by repo ID // GetProtectedBranchByRepoID getting protected branch by repo ID
func GetProtectedBranchByRepoID(repoID int64) ([]*ProtectedBranch, error) { func GetProtectedBranchByRepoID(repoID int64) ([]*ProtectedBranch, error) {
protectedBranches := make([]*ProtectedBranch, 0) protectedBranches := make([]*ProtectedBranch, 0)

View file

@ -916,6 +916,25 @@ func (err ErrFilePathInvalid) Error() string {
return fmt.Sprintf("path is invalid [path: %s]", err.Path) return fmt.Sprintf("path is invalid [path: %s]", err.Path)
} }
// ErrFilePathProtected represents a "FilePathProtected" kind of error.
type ErrFilePathProtected struct {
Message string
Path string
}
// IsErrFilePathProtected checks if an error is an ErrFilePathProtected.
func IsErrFilePathProtected(err error) bool {
_, ok := err.(ErrFilePathProtected)
return ok
}
func (err ErrFilePathProtected) Error() string {
if err.Message != "" {
return err.Message
}
return fmt.Sprintf("path is protected and can not be changed [path: %s]", err.Path)
}
// ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo. // ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo.
type ErrUserDoesNotHaveAccessToRepo struct { type ErrUserDoesNotHaveAccessToRepo struct {
UserID int64 UserID int64

View file

@ -196,6 +196,8 @@ var migrations = []Migration{
NewMigration("Expand webhooks for more granularity", expandWebhooks), NewMigration("Expand webhooks for more granularity", expandWebhooks),
// v131 -> v132 // v131 -> v132
NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn), NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn),
// v132 -> v133
NewMigration("Add Branch Protection Protected Files Column", addBranchProtectionProtectedFilesColumn),
} }
// Migrate database to current version // Migrate database to current version

22
models/migrations/v132.go Normal file
View file

@ -0,0 +1,22 @@
// Copyright 2020 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 migrations
import (
"fmt"
"xorm.io/xorm"
)
func addBranchProtectionProtectedFilesColumn(x *xorm.Engine) error {
type ProtectedBranch struct {
ProtectedFilePatterns string `xorm:"TEXT"`
}
if err := x.Sync2(new(ProtectedBranch)); err != nil {
return fmt.Errorf("Sync2: %v", err)
}
return nil
}

View file

@ -175,6 +175,7 @@ type ProtectBranchForm struct {
BlockOnRejectedReviews bool BlockOnRejectedReviews bool
DismissStaleApprovals bool DismissStaleApprovals bool
RequireSignedCommits bool RequireSignedCommits bool
ProtectedFilePatterns string
} }
// Validate validates the fields // Validate validates the fields

View file

@ -120,6 +120,7 @@ func ToBranchProtection(bp *models.ProtectedBranch) *api.BranchProtection {
BlockOnRejectedReviews: bp.BlockOnRejectedReviews, BlockOnRejectedReviews: bp.BlockOnRejectedReviews,
DismissStaleApprovals: bp.DismissStaleApprovals, DismissStaleApprovals: bp.DismissStaleApprovals,
RequireSignedCommits: bp.RequireSignedCommits, RequireSignedCommits: bp.RequireSignedCommits,
ProtectedFilePatterns: bp.ProtectedFilePatterns,
Created: bp.CreatedUnix.AsTime(), Created: bp.CreatedUnix.AsTime(),
Updated: bp.UpdatedUnix.AsTime(), Updated: bp.UpdatedUnix.AsTime(),
} }

View file

@ -60,21 +60,31 @@ func DeleteRepoFile(repo *models.Repository, doer *models.User, opts *DeleteRepo
if err != nil { if err != nil {
return nil, err return nil, err
} }
if protectedBranch != nil && !protectedBranch.CanUserPush(doer.ID) { if protectedBranch != nil {
return nil, models.ErrUserCannotCommit{ if !protectedBranch.CanUserPush(doer.ID) {
UserName: doer.LowerName,
}
}
if protectedBranch != nil && protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{ return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName, UserName: doer.LowerName,
} }
} }
if protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
}
patterns := protectedBranch.GetProtectedFilePatterns()
for _, pat := range patterns {
if pat.Match(strings.ToLower(opts.TreePath)) {
return nil, models.ErrFilePathProtected{
Path: opts.TreePath,
}
}
}
} }
} }

View file

@ -156,21 +156,31 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
if err != nil { if err != nil {
return nil, err return nil, err
} }
if protectedBranch != nil && !protectedBranch.CanUserPush(doer.ID) { if protectedBranch != nil {
return nil, models.ErrUserCannotCommit{ if !protectedBranch.CanUserPush(doer.ID) {
UserName: doer.LowerName,
}
}
if protectedBranch != nil && protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{ return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName, UserName: doer.LowerName,
} }
} }
if protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
}
patterns := protectedBranch.GetProtectedFilePatterns()
for _, pat := range patterns {
if pat.Match(strings.ToLower(opts.TreePath)) {
return nil, models.ErrFilePathProtected{
Path: opts.TreePath,
}
}
}
} }
} }

View file

@ -41,6 +41,7 @@ type BranchProtection struct {
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"` DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"` RequireSignedCommits bool `json:"require_signed_commits"`
ProtectedFilePatterns string `json:"protected_file_patterns"`
// swagger:strfmt date-time // swagger:strfmt date-time
Created time.Time `json:"created_at"` Created time.Time `json:"created_at"`
// swagger:strfmt date-time // swagger:strfmt date-time
@ -67,6 +68,7 @@ type CreateBranchProtectionOption struct {
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"` DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"` RequireSignedCommits bool `json:"require_signed_commits"`
ProtectedFilePatterns string `json:"protected_file_patterns"`
} }
// EditBranchProtectionOption options for editing a branch protection // EditBranchProtectionOption options for editing a branch protection
@ -88,4 +90,5 @@ type EditBranchProtectionOption struct {
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"` BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals *bool `json:"dismiss_stale_approvals"` DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
RequireSignedCommits *bool `json:"require_signed_commits"` RequireSignedCommits *bool `json:"require_signed_commits"`
ProtectedFilePatterns *string `json:"protected_file_patterns"`
} }

View file

@ -1488,6 +1488,8 @@ settings.dismiss_stale_approvals = Dismiss stale approvals
settings.dismiss_stale_approvals_desc = When new commits that change the content of the pull request are pushed to the branch, old approvals will be dismissed. settings.dismiss_stale_approvals_desc = When new commits that change the content of the pull request are pushed to the branch, old approvals will be dismissed.
settings.require_signed_commits = Require Signed Commits settings.require_signed_commits = Require Signed Commits
settings.require_signed_commits_desc = Reject pushes to this branch if they are unsigned or unverifiable settings.require_signed_commits_desc = Reject pushes to this branch if they are unsigned or unverifiable
settings.protect_protected_file_patterns = Protected file patterns (separated using semicolon ';'):
settings.protect_protected_file_patterns_desc = Protected files that are not allowed to be changed directly even if user has rights to add, edit or delete files in this branch. Multiple patterns can be separated using semicolon (';'). See <a href="https://godoc.org/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
settings.add_protected_branch = Enable protection settings.add_protected_branch = Enable protection
settings.delete_protected_branch = Disable protection settings.delete_protected_branch = Disable protection
settings.update_protect_branch_success = Branch protection for branch '%s' has been updated. settings.update_protect_branch_success = Branch protection for branch '%s' has been updated.

View file

@ -339,6 +339,7 @@ func CreateBranchProtection(ctx *context.APIContext, form api.CreateBranchProtec
BlockOnRejectedReviews: form.BlockOnRejectedReviews, BlockOnRejectedReviews: form.BlockOnRejectedReviews,
DismissStaleApprovals: form.DismissStaleApprovals, DismissStaleApprovals: form.DismissStaleApprovals,
RequireSignedCommits: form.RequireSignedCommits, RequireSignedCommits: form.RequireSignedCommits,
ProtectedFilePatterns: form.ProtectedFilePatterns,
} }
err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{ err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{
@ -470,6 +471,10 @@ func EditBranchProtection(ctx *context.APIContext, form api.EditBranchProtection
protectBranch.RequireSignedCommits = *form.RequireSignedCommits protectBranch.RequireSignedCommits = *form.RequireSignedCommits
} }
if form.ProtectedFilePatterns != nil {
protectBranch.ProtectedFilePatterns = *form.ProtectedFilePatterns
}
var whitelistUsers []int64 var whitelistUsers []int64
if form.PushWhitelistUsernames != nil { if form.PushWhitelistUsernames != nil {
whitelistUsers, err = models.GetUserIDsByNames(form.PushWhitelistUsernames, false) whitelistUsers, err = models.GetUserIDsByNames(form.PushWhitelistUsernames, false)

View file

@ -22,9 +22,10 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
pull_service "code.gitea.io/gitea/services/pull" pull_service "code.gitea.io/gitea/services/pull"
"github.com/go-git/go-git/v5/plumbing"
"gitea.com/macaron/macaron" "gitea.com/macaron/macaron"
"github.com/go-git/go-git/v5/plumbing"
"github.com/gobwas/glob"
) )
func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error { func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error {
@ -57,6 +58,52 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []
return err return err
} }
func checkFileProtection(oldCommitID, newCommitID string, patterns []glob.Glob, 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()
}()
err = git.NewCommand("diff", "--name-only", oldCommitID+"..."+newCommitID).
RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path,
stdoutWriter, nil, nil,
func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()
scanner := bufio.NewScanner(stdoutReader)
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
if len(path) == 0 {
continue
}
lpath := strings.ToLower(path)
for _, pat := range patterns {
if pat.Match(lpath) {
cancel()
return models.ErrFilePathProtected{
Path: path,
}
}
}
}
if err := scanner.Err(); err != nil {
return err
}
_ = stdoutReader.Close()
return err
})
if err != nil && !models.IsErrFilePathProtected(err) {
log.Error("Unable to check file protection for 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 { func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error {
scanner := bufio.NewScanner(input) scanner := bufio.NewScanner(input)
for scanner.Scan() { for scanner.Scan() {
@ -216,6 +263,26 @@ func HookPreReceive(ctx *macaron.Context, opts private.HookOptions) {
} }
} }
globs := protectBranch.GetProtectedFilePatterns()
if len(globs) > 0 {
err := checkFileProtection(oldCommitID, newCommitID, globs, gitRepo, env)
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, map[string]interface{}{
"err": fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err),
})
return
}
protectedFilePath := err.(models.ErrFilePathProtected).Path
log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath)
ctx.JSON(http.StatusForbidden, map[string]interface{}{
"err": fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath),
})
return
}
}
canPush := false canPush := false
if opts.IsDeployKey { if opts.IsDeployKey {
canPush = protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys) canPush = protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)

View file

@ -247,6 +247,7 @@ func SettingsProtectedBranchPost(ctx *context.Context, f auth.ProtectBranchForm)
protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews
protectBranch.DismissStaleApprovals = f.DismissStaleApprovals protectBranch.DismissStaleApprovals = f.DismissStaleApprovals
protectBranch.RequireSignedCommits = f.RequireSignedCommits protectBranch.RequireSignedCommits = f.RequireSignedCommits
protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns
err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{ err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{
UserIDs: whitelistUsers, UserIDs: whitelistUsers,

View file

@ -225,6 +225,11 @@
<p class="help">{{.i18n.Tr "repo.settings.require_signed_commits_desc"}}</p> <p class="help">{{.i18n.Tr "repo.settings.require_signed_commits_desc"}}</p>
</div> </div>
</div> </div>
<div class="field">
<label for="protected_file_patterns">{{.i18n.Tr "repo.settings.protect_protected_file_patterns"}}</label>
<input name="protected_file_patterns" id="protected_file_patterns" type="text" value="{{.Branch.ProtectedFilePatterns}}">
<p class="help">{{.i18n.Tr "repo.settings.protect_protected_file_patterns_desc" | Safe}}</p>
</div>
</div> </div>

View file

@ -9818,6 +9818,10 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
},
"push_whitelist_deploy_keys": { "push_whitelist_deploy_keys": {
"type": "boolean", "type": "boolean",
"x-go-name": "PushWhitelistDeployKeys" "x-go-name": "PushWhitelistDeployKeys"
@ -10129,6 +10133,10 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
},
"push_whitelist_deploy_keys": { "push_whitelist_deploy_keys": {
"type": "boolean", "type": "boolean",
"x-go-name": "PushWhitelistDeployKeys" "x-go-name": "PushWhitelistDeployKeys"
@ -10933,6 +10941,10 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
},
"push_whitelist_deploy_keys": { "push_whitelist_deploy_keys": {
"type": "boolean", "type": "boolean",
"x-go-name": "PushWhitelistDeployKeys" "x-go-name": "PushWhitelistDeployKeys"