fix(repo): reject unparsable required approvals in branch protection (#40213)

Saving a branch protection rule with a "Required approvals" value that
can't be parsed as an integer (like `99999999999999999999` or `abc`)
showed the success message and stored 0, because the binding error was
never checked. The form now uses `form-fetch-action` with
`GetFetchActionForm`, so invalid input is rejected and nothing is saved.
Dev mode still answers unparsable numbers with a 500 until
`TypeCastError` gets a message, see
https://github.com/go-gitea/gitea/pull/40212.

- Validation errors show on the page and highlight the offending field,
keeping the form input
- Negative approvals are rejected before any rule lookup, and the input
gets `min="0"`

Fixes https://github.com/go-gitea/gitea/issues/40211

Assisted-by: Claude Code:claude-opus-5-5

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Lazizbek Ergashev
2026-11-06 08:08:20 +00:00
committed by GitHub
parent 09922e4ae1
commit 98c937170f
8 changed files with 27 additions and 34 deletions

View File

@@ -2487,7 +2487,6 @@
"repo.settings.choose_branch": "Choose a branch…", "repo.settings.choose_branch": "Choose a branch…",
"repo.settings.no_protected_branch": "There are no protected branches.", "repo.settings.no_protected_branch": "There are no protected branches.",
"repo.settings.edit_protected_branch": "Edit", "repo.settings.edit_protected_branch": "Edit",
"repo.settings.protected_branch_required_rule_name": "Required rule name",
"repo.settings.protected_branch_duplicate_rule_name": "Duplicate rule name", "repo.settings.protected_branch_duplicate_rule_name": "Duplicate rule name",
"repo.settings.protected_branch_required_approvals_min": "Required approvals cannot be negative.", "repo.settings.protected_branch_required_approvals_min": "Required approvals cannot be negative.",
"repo.settings.tags": "Tags", "repo.settings.tags": "Tags",

View File

@@ -5,9 +5,7 @@ package setting
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -124,14 +122,15 @@ func SettingsProtectedBranch(c *context.Context) {
// SettingsProtectedBranchPost updates the protected branch settings // SettingsProtectedBranchPost updates the protected branch settings
func SettingsProtectedBranchPost(ctx *context.Context) { func SettingsProtectedBranchPost(ctx *context.Context) {
f := web.GetForm[*forms.ProtectBranchForm](ctx) f := context.GetFetchActionForm[*forms.ProtectBranchForm](ctx)
var protectBranch *git_model.ProtectedBranch if f == nil {
if f.RuleName == "" {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_rule_name"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/branches/edit")
return return
} }
if f.RequiredApprovals < 0 {
ctx.JSONErrorWithField(ctx.Tr("repo.settings.protected_branch_required_approvals_min"), "required_approvals")
return
}
var protectBranch *git_model.ProtectedBranch
var err error var err error
if f.RuleID > 0 { if f.RuleID > 0 {
// If the RuleID isn't 0, it must be an edit operation. So we get rule by id. // If the RuleID isn't 0, it must be an edit operation. So we get rule by id.
@@ -149,8 +148,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
return return
} }
if sameNameProtectBranch != nil { if sameNameProtectBranch != nil {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name")) ctx.JSONErrorWithField(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name"), "rule_name")
ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
return return
} }
} }
@@ -176,11 +174,6 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, deletionAllowlistUsers, deletionAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams, bypassAllowlistUsers, bypassAllowlistTeams []int64 var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, deletionAllowlistUsers, deletionAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams, bypassAllowlistUsers, bypassAllowlistTeams []int64
protectBranch.RuleName = f.RuleName protectBranch.RuleName = f.RuleName
if f.RequiredApprovals < 0 {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, f.RuleName))
return
}
switch f.EnablePush { switch f.EnablePush {
case "all": case "all":
@@ -271,16 +264,14 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
continue continue
} }
if _, err := glob.Compile(trimmed); err != nil { if _, err := glob.Compile(trimmed); err != nil {
ctx.Flash.Error(ctx.Tr("repo.settings.protect_invalid_status_check_pattern", pattern)) ctx.JSONErrorWithField(ctx.Tr("repo.settings.protect_invalid_status_check_pattern", pattern), "status_check_contexts")
ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName)))
return return
} }
validPatterns = append(validPatterns, trimmed) validPatterns = append(validPatterns, trimmed)
} }
if len(validPatterns) == 0 { if len(validPatterns) == 0 {
// if status check is enabled, patterns slice is not allowed to be empty // if status check is enabled, patterns slice is not allowed to be empty
ctx.Flash.Error(ctx.Tr("repo.settings.protect_no_valid_status_check_patterns")) ctx.JSONErrorWithField(ctx.Tr("repo.settings.protect_no_valid_status_check_patterns"), "status_check_contexts")
ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName)))
return return
} }
protectBranch.StatusCheckContexts = validPatterns protectBranch.StatusCheckContexts = validPatterns
@@ -334,7 +325,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
} }
ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName)) ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName))
ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches")
} }
// DeleteProtectedBranchRulePost delete protected branch rule by id // DeleteProtectedBranchRulePost delete protected branch rule by id

View File

@@ -1181,7 +1181,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/branches", func() { m.Group("/branches", func() {
m.Get("/", repo_setting.ProtectedBranchRules) m.Get("/", repo_setting.ProtectedBranchRules)
m.Combo("/edit").Get(repo_setting.SettingsProtectedBranch). m.Combo("/edit").Get(repo_setting.SettingsProtectedBranch).
Post(web.Bind[*forms.ProtectBranchForm](), context.RepoMustNotBeArchived(), repo_setting.SettingsProtectedBranchPost) Post(context.RepoMustNotBeArchived(), repo_setting.SettingsProtectedBranchPost)
m.Post("/{id}/delete", repo_setting.DeleteProtectedBranchRulePost) m.Post("/{id}/delete", repo_setting.DeleteProtectedBranchRulePost)
m.Post("/priority", context.RepoMustNotBeArchived(), repo_setting.UpdateBranchProtectionPriories) m.Post("/priority", context.RepoMustNotBeArchived(), repo_setting.UpdateBranchProtectionPriories)
}) })

View File

@@ -1,6 +1,6 @@
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings branches")}} {{template "repo/settings/layout_head" (dict "pageClass" "repository settings branches")}}
<div class="repo-setting-content"> <div class="repo-setting-content">
<form class="ui form" action="{{.Link}}" method="post"> <form class="ui form form-fetch-action" action="{{.Link}}" method="post">
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{if .Rule.RuleName}} {{if .Rule.RuleName}}
{{ctx.Locale.Tr "repo.settings.branch_protection" .Rule.RuleName}} {{ctx.Locale.Tr "repo.settings.branch_protection" .Rule.RuleName}}
@@ -145,7 +145,7 @@
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_pull_request_approvals"}}</h5> <h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.event_pull_request_approvals"}}</h5>
<div class="field"> <div class="field">
<label>{{ctx.Locale.Tr "repo.settings.protect_required_approvals"}}</label> <label>{{ctx.Locale.Tr "repo.settings.protect_required_approvals"}}</label>
<input name="required_approvals" type="number" value="{{.Rule.RequiredApprovals}}"> <input name="required_approvals" type="number" min="0" value="{{.Rule.RequiredApprovals}}">
<p class="help tw-ml-0">{{ctx.Locale.Tr "repo.settings.protect_required_approvals_desc"}}</p> <p class="help tw-ml-0">{{ctx.Locale.Tr "repo.settings.protect_required_approvals_desc"}}</p>
</div> </div>
<div class="grouped fields"> <div class="grouped fields">

View File

@@ -252,7 +252,7 @@ jobs:
pbValues["enable_status_check"] = "true" pbValues["enable_status_check"] = "true"
pbValues["status_check_contexts"] = "ci/manual" pbValues["status_check_contexts"] = "ci/manual"
} }
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther) user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusOK)
prFile := &api.CreateFileOptions{ prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{ FileOptions: api.FileOptions{

View File

@@ -91,12 +91,15 @@ func testCreateFile(t *testing.T, session *TestSession, user, repo, baseBranchNa
func testEditorProtectedBranch(t *testing.T) { func testEditorProtectedBranch(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
assert.JSONEq(t, `{"errorMessage":"Required approvals cannot be negative.","renderFormat":"html","errorFields":["required_approvals"]}`,
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{"rule_name": "master", "required_approvals": "-1"}), http.StatusBadRequest).Body.String())
// Change the "master" branch to "protected" // Change the "master" branch to "protected"
req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
"rule_name": "master", "rule_name": "master",
"enable_push": "true", "enable_push": "true",
}) })
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusOK)
flashMsg := session.GetCookieFlashMessage() flashMsg := session.GetCookieFlashMessage()
assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg) assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)
@@ -112,7 +115,7 @@ func testEditorProtectedBranch(t *testing.T) {
"unprotected_file_patterns": "docs/*.md", "unprotected_file_patterns": "docs/*.md",
"enable_push": "true", "enable_push": "true",
}) })
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusOK)
flashMsg = session.GetCookieFlashMessage() flashMsg = session.GetCookieFlashMessage()
assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg) assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)

View File

@@ -553,8 +553,8 @@ func doProtectBranchExt(ctx APITestContext, ruleName string, opts doProtectBranc
} }
// Send the request to update branch protection settings // Send the request to update branch protection settings
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), formData) link := fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame))
ctx.Session.MakeRequest(t, req, http.StatusSeeOther) assert.Equal(t, link, test.RedirectURL(ctx.Session.MakeRequest(t, NewRequestWithValues(t, "POST", link+"/edit", formData), http.StatusOK)))
// Check if the "master" branch has been locked successfully // Check if the "master" branch has been locked successfully
flashMsg := ctx.Session.GetCookieFlashMessage() flashMsg := ctx.Session.GetCookieFlashMessage()

View File

@@ -829,7 +829,7 @@ func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
"enable_status_check": "true", "enable_status_check": "true",
"status_check_contexts": "gitea/actions", "status_check_contexts": "gitea/actions",
}) })
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusOK)
addToQueuePullChan := make(chan automergequeue.AutoMergeItem, 1) addToQueuePullChan := make(chan automergequeue.AutoMergeItem, 1)
resetAutoMergeQueueMock := test.MockVariableValue(&automergequeue.AddToQueue, func(item automergequeue.AutoMergeItem) { addToQueuePullChan <- item }) resetAutoMergeQueueMock := test.MockVariableValue(&automergequeue.AddToQueue, func(item automergequeue.AutoMergeItem) { addToQueuePullChan <- item })
@@ -911,7 +911,7 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApproval(t *testing.T) {
"status_check_contexts": "gitea/actions", "status_check_contexts": "gitea/actions",
"required_approvals": "1", "required_approvals": "1",
}) })
baseSession.MakeRequest(t, req, http.StatusSeeOther) baseSession.MakeRequest(t, req, http.StatusOK)
// first time insert automerge record, return true // first time insert automerge record, return true
scheduled, err := automerge.ScheduleAutoMerge(t.Context(), baseUser, pr, repo_model.MergeStyleMerge, "auto merge test", false) scheduled, err := automerge.ScheduleAutoMerge(t.Context(), baseUser, pr, repo_model.MergeStyleMerge, "auto merge test", false)
@@ -1023,7 +1023,7 @@ func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing.
"status_check_contexts": "gitea/actions", "status_check_contexts": "gitea/actions",
"required_approvals": "1", "required_approvals": "1",
}) })
session.MakeRequest(t, req, http.StatusSeeOther) session.MakeRequest(t, req, http.StatusOK)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// first time insert automerge record, return true // first time insert automerge record, return true
@@ -1098,7 +1098,7 @@ func TestPullNonMergeForAdminWithBranchProtection(t *testing.T) {
"status_check_contexts": "gitea/actions", "status_check_contexts": "gitea/actions",
"block_admin_merge_override": "true", "block_admin_merge_override": "true",
}) })
session.MakeRequest(t, pbCreateReq, http.StatusSeeOther) session.MakeRequest(t, pbCreateReq, http.StatusOK)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
@@ -1144,7 +1144,7 @@ func TestPullForceMergeForBypassAllowlistUser(t *testing.T) {
"enable_bypass_allowlist": "on", "enable_bypass_allowlist": "on",
"bypass_allowlist_users": strconv.FormatInt(bypassUser.ID, 10), "bypass_allowlist_users": strconv.FormatInt(bypassUser.ID, 10),
}) })
ownerSession.MakeRequest(t, pbCreateReq, http.StatusSeeOther) ownerSession.MakeRequest(t, pbCreateReq, http.StatusOK)
defer testAPIDeleteBranchProtection(t, "master", http.StatusNoContent) defer testAPIDeleteBranchProtection(t, "master", http.StatusNoContent)
token := getTokenForLoggedInUser(t, bypassSession, auth_model.AccessTokenScopeWriteRepository) token := getTokenForLoggedInUser(t, bypassSession, auth_model.AccessTokenScopeWriteRepository)