Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package semaphore for openSUSE:Factory 
checked in at 2026-11-10 14:43:24
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/semaphore (Old)
 and      /work/SRC/openSUSE:Factory/.semaphore.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "semaphore"

Tue Nov 10 14:43:24 2026 rev:61 rq:1396459 version:2.19.21

Changes:
--------
--- /work/SRC/openSUSE:Factory/semaphore/semaphore.changes      2026-11-06 
19:52:50.853296577 +0100
+++ /work/SRC/openSUSE:Factory/.semaphore.new.3759290/semaphore.changes 
2026-11-10 14:45:04.628270606 +0100
@@ -1,0 +2,16 @@
+Tue Nov 10 06:16:26 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 2.19.21:
+  * Bugfixes
+    - A user can change their own password through PUT
+      /api/users/{user_id} without proving knowledge of the current
+      one
+      
https://github.com/semaphoreui/semaphore/security/advisories/GHSA-3gg5-457g-m9cg
+- Update to version 2.19.20 (.19 was not released):
+  * Bugfixes
+    - Manager can move another Semaphore UI project's template
+      vault binding by ID
+      
https://github.com/semaphoreui/semaphore/security/advisories/GHSA-ffp6-w57q-758q
+    - fix: validate survey variables
+
+-------------------------------------------------------------------

Old:
----
  semaphore-2.19.18.obscpio
  web-2.19.18.tar.gz

New:
----
  semaphore-2.19.21.obscpio
  web-2.19.21.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ semaphore.spec ++++++
--- /var/tmp/diff_new_pack.R64EA1/_old  2026-11-10 14:45:11.487557963 +0100
+++ /var/tmp/diff_new_pack.R64EA1/_new  2026-11-10 14:45:11.489558047 +0100
@@ -17,7 +17,7 @@
 
 
 Name:           semaphore
-Version:        2.19.18
+Version:        2.19.21
 Release:        0
 Summary:        Modern UI for Ansible
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.R64EA1/_old  2026-11-10 14:45:11.578561776 +0100
+++ /var/tmp/diff_new_pack.R64EA1/_new  2026-11-10 14:45:11.585562069 +0100
@@ -4,8 +4,8 @@
     <param name="scm">git</param>
     <param name="submodules">disable</param>
     <param name="exclude">.git</param>
-    <param name="revision">refs/tags/v2.19.18</param>
-    <param name="match-tag">v2.19.18</param>
+    <param name="revision">refs/tags/v2.19.21</param>
+    <param name="match-tag">v2.19.21</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>
     <param name="changesgenerate">enable</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.R64EA1/_old  2026-11-10 14:45:11.611563158 +0100
+++ /var/tmp/diff_new_pack.R64EA1/_new  2026-11-10 14:45:11.614563284 +0100
@@ -3,6 +3,6 @@
                 <param 
name="url">https://github.com/ansible-semaphore/semaphore</param>
               <param 
name="changesrevision">8a4dcf0868af718aaa5871368a3247dd622521f4</param></service><service
 name="tar_scm">
                 <param 
name="url">https://github.com/ansible-semaphore/semaphore.git</param>
-              <param 
name="changesrevision">b621d1453a4eba54b5e162e2f572495d26944221</param></service></servicedata>
+              <param 
name="changesrevision">154ba40dec42033cca419e19ef57488cc1aa6554</param></service></servicedata>
 (No newline at EOF)
 

++++++ semaphore-2.19.18.obscpio -> semaphore-2.19.21.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/api/projects/tasks.go 
new/semaphore-2.19.21/api/projects/tasks.go
--- old/semaphore-2.19.18/api/projects/tasks.go 2026-11-02 21:03:04.000000000 
+0100
+++ new/semaphore-2.19.21/api/projects/tasks.go 2026-11-09 22:49:37.000000000 
+0100
@@ -48,6 +48,11 @@
                return
        }
 
+       if err = db.ValidateTaskSurveyValues(tpl.SurveyVars, 
taskObj.Environment, taskObj.Secret); err != nil {
+               helpers.WriteError(w, err)
+               return
+       }
+
        newTask, err := taskPool(r).AddTask(
                taskObj,
                &user.ID,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/api/users.go 
new/semaphore-2.19.21/api/users.go
--- old/semaphore-2.19.18/api/users.go  2026-11-02 21:03:04.000000000 +0100
+++ new/semaphore-2.19.21/api/users.go  2026-11-09 22:49:37.000000000 +0100
@@ -172,14 +172,22 @@
        })
 }
 
+// userUpdateRequest is the body of PUT /users/{user_id}. CurrentPwd is read 
only when the body also carries
+// a new password, see allowPasswordChange.
+type userUpdateRequest struct {
+       db.UserWithPwd
+       CurrentPwd string `json:"current_password"`
+}
+
 func (c *UsersController) UpdateUser(w http.ResponseWriter, r *http.Request) {
        targetUser := helpers.GetFromContext(r, "_user").(db.User)
        editor := helpers.GetFromContext(r, "user").(*db.User)
 
-       var user db.UserWithPwd
-       if !helpers.Bind(w, r, &user) {
+       var req userUpdateRequest
+       if !helpers.Bind(w, r, &req) {
                return
        }
+       user := req.UserWithPwd
 
        if !editor.Admin && (user.Pro && !targetUser.Pro) {
                c.log.WithFields(log.Fields{
@@ -230,6 +238,11 @@
                return
        }
 
+       // The password column is also written by POST 
/users/{user_id}/password; both routes apply one rule.
+       if user.Pwd != "" && !c.allowPasswordChange(w, editor, targetUser, 
req.CurrentPwd) {
+               return
+       }
+
        user.ID = targetUser.ID
        if err := helpers.Store(r).UpdateUser(user); err != nil {
                c.log.WithError(err).WithField("user_id", 
targetUser.ID).Error("Failed to update user")
@@ -258,25 +271,12 @@
                return
        }
 
-       if user.External {
-               c.log.WithField("user_id", user.ID).Debug("Password is not 
editable for external users")
-               w.WriteHeader(http.StatusBadRequest)
-               return
-       }
-
        if !helpers.Bind(w, r, &pwd) {
                return
        }
 
-       // CWE-620: require the current password when a user changes their own,
-       // so a stolen session can't be used to take over the account. Admins
-       // changing someone else's password are exempt — they can't know it.
-       if editor.ID == user.ID {
-               if err := bcrypt.CompareHashAndPassword([]byte(user.Password), 
[]byte(pwd.CurrentPwd)); err != nil {
-                       c.log.WithField("user_id", user.ID).Debug("Current 
password does not match")
-                       helpers.WriteErrorStatus(w, "Current password is 
incorrect", http.StatusBadRequest)
-                       return
-               }
+       if !c.allowPasswordChange(w, editor, user, pwd.CurrentPwd) {
+               return
        }
 
        if err := helpers.Store(r).SetUserPassword(user.ID, pwd.Pwd); err != 
nil {
@@ -288,6 +288,39 @@
        w.WriteHeader(http.StatusNoContent)
 }
 
+// allowPasswordChange reports whether editor may give target a new password. 
When it does not, it has
+// already answered the request. Every handler that writes a password calls it 
before the write, so that
+// sibling routes cannot drift apart (GHSA-3gg5-457g-m9cg).
+//
+// CWE-620: a user changing their own password must give the current one, so 
that a stolen session or API
+// token can't be turned into the account's credential. This holds for admins 
too. An admin resetting
+// someone else's password is exempt: they can't know it, and the handlers 
admit no other editor.
+// External users have no local password to change.
+func (c *UsersController) allowPasswordChange(
+       w http.ResponseWriter,
+       editor *db.User,
+       target db.User,
+       currentPwd string,
+) bool {
+       if target.External {
+               c.log.WithField("user_id", target.ID).Debug("Password is not 
editable for external users")
+               w.WriteHeader(http.StatusBadRequest)
+               return false
+       }
+
+       if editor.ID != target.ID {
+               return true
+       }
+
+       if err := bcrypt.CompareHashAndPassword([]byte(target.Password), 
[]byte(currentPwd)); err != nil {
+               c.log.WithField("user_id", target.ID).Debug("Current password 
does not match")
+               helpers.WriteErrorStatus(w, "Current password is incorrect", 
http.StatusBadRequest)
+               return false
+       }
+
+       return true
+}
+
 func (c *UsersController) DeleteUser(w http.ResponseWriter, r *http.Request) {
        user := helpers.GetFromContext(r, "_user").(db.User)
        editor := helpers.GetFromContext(r, "user").(*db.User)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/api/users_test.go 
new/semaphore-2.19.21/api/users_test.go
--- old/semaphore-2.19.18/api/users_test.go     2026-11-02 21:03:04.000000000 
+0100
+++ new/semaphore-2.19.21/api/users_test.go     2026-11-09 22:49:37.000000000 
+0100
@@ -2,6 +2,7 @@
 
 import (
        "bytes"
+       "encoding/json"
        "net/http"
        "net/http/httptest"
        "testing"
@@ -10,10 +11,17 @@
        "github.com/semaphoreui/semaphore/db"
        "github.com/semaphoreui/semaphore/db/sql"
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
 )
 
 func newPasswordRequest(store db.Store, editor *db.User, target db.User, body 
string) *http.Request {
-       r := httptest.NewRequest(http.MethodPost, "/api/users/1/password", 
bytes.NewBufferString(body))
+       return newUserRequest(store, http.MethodPost, "/api/users/1/password", 
editor, target, body)
+}
+
+// newUserRequest builds a request the way GetUserMiddleware hands it to a 
handler: editor is the caller,
+// target the user from the path.
+func newUserRequest(store db.Store, method string, path string, editor 
*db.User, target db.User, body string) *http.Request {
+       r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
        r = helpers.SetContextValue(r, "store", store)
        r = helpers.SetContextValue(r, "user", editor)
        r = helpers.SetContextValue(r, "_user", target)
@@ -60,3 +68,137 @@
        NewUsersController(nil).UpdateUserPassword(w, r)
        assert.Equal(t, http.StatusNoContent, w.Code)
 }
+
+// passwordTestUsers creates a user, an admin and an external user. The first 
two have the password
+// verystrongpassword1, the external one has none.
+func passwordTestUsers(t *testing.T, store db.Store) map[string]db.User {
+       t.Helper()
+       external, err := store.CreateUserWithoutPassword(db.User{
+               Username: "ext",
+               Name:     "ext",
+               Email:    "[email protected]",
+               External: true,
+       })
+       require.NoError(t, err)
+       admin := createUserOptionsTestUser(t, store, "admin")
+       admin.Admin = true
+       require.NoError(t, store.UpdateUser(db.UserWithPwd{User: admin}))
+       return map[string]db.User{
+               "alice": createUserOptionsTestUser(t, store, "alice"),
+               "admin": admin,
+               "ext":   external,
+       }
+}
+
+// passwordBody encodes fields, with current_password only when currentPwd is 
given.
+func passwordBody(t *testing.T, fields map[string]any, currentPwd string) 
string {
+       t.Helper()
+       if currentPwd != "" {
+               fields["current_password"] = currentPwd
+       }
+       raw, err := json.Marshal(fields)
+       require.NoError(t, err)
+       return string(raw)
+}
+
+func storedPasswordHash(t *testing.T, store db.Store, userID int) string {
+       t.Helper()
+       user, err := store.GetUser(userID)
+       require.NoError(t, err)
+       return user.Password
+}
+
+// Both routes write the password column, so both must apply one rule. PUT 
/users/{user_id} used to let a
+// user set their own password without the current one (GHSA-3gg5-457g-m9cg).
+func TestPasswordChange_SameRuleOnBothRoutes(t *testing.T) {
+       const oldPwd = "verystrongpassword1" // see createUserOptionsTestUser
+       const newPwd = "brandnewpassword2"
+
+       routes := []struct {
+               name    string
+               method  string
+               handler func(*UsersController, http.ResponseWriter, 
*http.Request)
+               body    func(t *testing.T, target db.User, currentPwd string) 
string
+       }{
+               {
+                       name:    "PUT /users/{user_id}",
+                       method:  http.MethodPut,
+                       handler: (*UsersController).UpdateUser,
+                       body: func(t *testing.T, target db.User, currentPwd 
string) string {
+                               // The profile as it is, so that only the 
password changes.
+                               return passwordBody(t, map[string]any{
+                                       "username": target.Username,
+                                       "name":     target.Name,
+                                       "email":    target.Email,
+                                       "admin":    target.Admin,
+                                       "password": newPwd,
+                               }, currentPwd)
+                       },
+               },
+               {
+                       name:    "POST /users/{user_id}/password",
+                       method:  http.MethodPost,
+                       handler: (*UsersController).UpdateUserPassword,
+                       body: func(t *testing.T, _ db.User, currentPwd string) 
string {
+                               return passwordBody(t, 
map[string]any{"password": newPwd}, currentPwd)
+                       },
+               },
+       }
+
+       tests := []struct {
+               name        string
+               editor      string
+               target      string
+               currentPwd  string
+               wantCode    int
+               wantChanged bool
+       }{
+               {"user, own password, current one given", "alice", "alice", 
oldPwd, http.StatusNoContent, true},
+               {"user, own password, wrong current one", "alice", "alice", 
"wrongpassword", http.StatusBadRequest, false},
+               {"user, own password, no current one", "alice", "alice", "", 
http.StatusBadRequest, false},
+               {"admin, own password, current one given", "admin", "admin", 
oldPwd, http.StatusNoContent, true},
+               {"admin, own password, no current one", "admin", "admin", "", 
http.StatusBadRequest, false},
+               {"admin resets another user's password", "admin", "alice", "", 
http.StatusNoContent, true},
+               {"admin, external user's password", "admin", "ext", "", 
http.StatusBadRequest, false},
+               {"external user, own password", "ext", "ext", "", 
http.StatusBadRequest, false},
+       }
+
+       for _, route := range routes {
+               t.Run(route.name, func(t *testing.T) {
+                       for _, tt := range tests {
+                               t.Run(tt.name, func(t *testing.T) {
+                                       store := sql.CreateTestStore()
+                                       users := passwordTestUsers(t, store)
+                                       editor, target := users[tt.editor], 
users[tt.target]
+                                       hashBefore := storedPasswordHash(t, 
store, target.ID)
+                                       r := newUserRequest(store, 
route.method, "/api/users/1", &editor, target, route.body(t, target, 
tt.currentPwd))
+                                       w := httptest.NewRecorder()
+
+                                       route.handler(NewUsersController(nil), 
w, r)
+
+                                       assert.Equal(t, tt.wantCode, w.Code)
+                                       assert.Equal(t, tt.wantChanged, 
storedPasswordHash(t, store, target.ID) != hashBefore, "password written")
+                                       _, err := loginByPassword(store, 
target.Username, newPwd)
+                                       assert.Equal(t, tt.wantChanged, err == 
nil, "the new password logs in")
+                               })
+                       }
+               })
+       }
+}
+
+// Editing one's own profile does not touch the password and needs no current 
one.
+func TestUpdateUser_OwnProfileNeedsNoCurrentPassword(t *testing.T) {
+       store := sql.CreateTestStore()
+       bob := createUserOptionsTestUser(t, store, "bob")
+       r := newUserRequest(store, http.MethodPut, "/api/users/1", &bob, bob,
+               `{"username":"bob","name":"Robert","email":"[email protected]"}`)
+       w := httptest.NewRecorder()
+
+       NewUsersController(nil).UpdateUser(w, r)
+
+       require.Equal(t, http.StatusNoContent, w.Code)
+       got, err := store.GetUser(bob.ID)
+       require.NoError(t, err)
+       assert.Equal(t, "Robert", got.Name)
+       assert.Equal(t, bob.Password, got.Password, "the password is kept")
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/db/Task_survey.go 
new/semaphore-2.19.21/db/Task_survey.go
--- old/semaphore-2.19.18/db/Task_survey.go     1970-01-01 01:00:00.000000000 
+0100
+++ new/semaphore-2.19.21/db/Task_survey.go     2026-11-09 22:49:37.000000000 
+0100
@@ -0,0 +1,160 @@
+package db
+
+import (
+       "encoding/json"
+       "math"
+)
+
+// surveyVarSecret is the survey type the UI uses for secret variables; it has
+// no constant in this branch.
+const surveyVarSecret SurveyVarType = "secret"
+
+// ValidateTaskSurveyValues checks the survey answers a user submitted with a
+// new task against the template's survey. A key that is not declared in the
+// survey is rejected: every key becomes an extra var / -var / CLI argument of
+// the task and overrides the template's variable group, so an undeclared key
+// would let a user who may only run tasks set arbitrary variables (for
+// Ansible, connection variables such as ansible_ssh_common_args). An absent,
+// null or empty answer is accepted.
+//
+// Both maps are checked because Secret is merged over Environment when the
+// task runs (see LocalExecutor.getEnvironmentExtraVars): checking only
+// Environment would let an answer bypass validation through Secret.
+func ValidateTaskSurveyValues(vars []SurveyVar, environment string, secret 
string) error {
+       env, err := parseSurveyAnswers(environment, "environment")
+       if err != nil {
+               return err
+       }
+
+       sec, err := parseSurveyAnswers(secret, "secret")
+       if err != nil {
+               return err
+       }
+
+       declared := make(map[string]struct{}, len(vars))
+       for _, v := range vars {
+               declared[v.Name] = struct{}{}
+       }
+       for _, answers := range []map[string]any{env, sec} {
+               for name := range answers {
+                       if _, ok := declared[name]; !ok {
+                               return NewValidationError(
+                                       "variable \"" + name + "\" is not 
declared in the template survey")
+                       }
+               }
+       }
+
+       for _, v := range vars {
+               if val, ok := env[v.Name]; ok {
+                       if v.Type == surveyVarSecret && 
!isEmptySurveyAnswer(val) {
+                               // The environment is stored on the task in 
clear.
+                               return NewValidationError(
+                                       "survey variable \"" + v.Name + "\" is 
a secret and must be passed in the secret field")
+                       }
+                       if err = validateSurveyAnswer(v, val); err != nil {
+                               return err
+                       }
+               }
+
+               if val, ok := sec[v.Name]; ok {
+                       if err = validateSurveyAnswer(v, val); err != nil {
+                               return err
+                       }
+               }
+       }
+
+       return nil
+}
+
+func parseSurveyAnswers(raw string, field string) (map[string]any, error) {
+       res := make(map[string]any)
+       if raw == "" {
+               return res, nil
+       }
+       if err := json.Unmarshal([]byte(raw), &res); err != nil {
+               return nil, NewValidationError("task " + field + " must be a 
JSON object")
+       }
+       return res, nil
+}
+
+func isEmptySurveyAnswer(val any) bool {
+       switch v := val.(type) {
+       case nil:
+               return true
+       case string:
+               return v == ""
+       case []any:
+               return len(v) == 0
+       }
+       return false
+}
+
+// isSurveyInt reports whether s is an optionally negative decimal integer.
+func isSurveyInt(s string) bool {
+       if len(s) > 0 && s[0] == '-' {
+               s = s[1:]
+       }
+       if s == "" {
+               return false
+       }
+       for _, c := range s {
+               if c < '0' || c > '9' {
+                       return false
+               }
+       }
+       return true
+}
+
+func surveyAnswerAllowed(v SurveyVar, val any) bool {
+       s, ok := val.(string)
+       if !ok {
+               return false
+       }
+       for _, ev := range v.Values {
+               if ev.Value == s {
+                       return true
+               }
+       }
+       return false
+}
+
+func validateSurveyAnswer(v SurveyVar, val any) error {
+       if isEmptySurveyAnswer(val) {
+               return nil
+       }
+
+       invalid := func(msg string) error {
+               return NewValidationError("survey variable \"" + v.Name + "\": 
" + msg)
+       }
+
+       // The SurveyVar* constants are declared as TemplateType in this branch.
+       switch string(v.Type) {
+       case string(SurveyVarInt):
+               switch n := val.(type) {
+               case string:
+                       if !isSurveyInt(n) {
+                               return invalid("value must be an integer")
+                       }
+               case float64:
+                       if n != math.Trunc(n) {
+                               return invalid("value must be an integer")
+                       }
+               default:
+                       return invalid("value must be an integer")
+               }
+
+       case string(SurveyVarEnum):
+               if !surveyAnswerAllowed(v, val) {
+                       return invalid("value is not in values list")
+               }
+
+       default:
+               // String, text and secret: scalar values only.
+               switch val.(type) {
+               case []any, map[string]any:
+                       return invalid("value must be a string")
+               }
+       }
+
+       return nil
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/db/Task_survey_test.go 
new/semaphore-2.19.21/db/Task_survey_test.go
--- old/semaphore-2.19.18/db/Task_survey_test.go        1970-01-01 
01:00:00.000000000 +0100
+++ new/semaphore-2.19.21/db/Task_survey_test.go        2026-11-09 
22:49:37.000000000 +0100
@@ -0,0 +1,53 @@
+package db
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestValidateTaskSurveyValues(t *testing.T) {
+       values := []SurveyVarEnumValue{{Name: "A", Value: "a"}, {Name: "B", 
Value: "b"}}
+       vars := []SurveyVar{
+               {Name: "str"},
+               {Name: "num", Type: SurveyVarType(SurveyVarInt)},
+               {Name: "env", Type: SurveyVarType(SurveyVarEnum), Values: 
values},
+               {Name: "pwd", Type: surveyVarSecret},
+       }
+
+       tests := []struct {
+               name    string
+               env     string
+               secret  string
+               wantErr bool
+       }{
+               {"empty", "", "", false},
+               {"valid answers", `{"str":"x","num":"-12","env":"a"}`, 
`{"pwd":"p"}`, false},
+               {"int as json number", `{"num":5}`, "", false},
+               {"empty answers", `{"str":"","num":"","env":null,"pwd":""}`, 
"", false},
+               {"undeclared var in environment", `{"other":"x"}`, "", true},
+               {"undeclared var in secret", "", 
`{"ansible_ssh_common_args":"-o ProxyCommand=id"}`, true},
+               {"semaphore_vars is undeclared", `{"semaphore_vars":{}}`, "", 
true},
+               {"invalid environment json", `[1]`, "", true},
+               {"invalid secret json", "", `x`, true},
+               {"int not a number", `{"num":"1; rm -rf /"}`, "", true},
+               {"int fractional", `{"num":1.5}`, "", true},
+               {"enum not in values", `{"env":"c"}`, "", true},
+               {"enum not a string", `{"env":["a"]}`, "", true},
+               {"string is an object", `{"str":{"a":1}}`, "", true},
+               {"secret in environment", `{"pwd":"p"}`, "", true},
+               {"enum bypass via secret", "", `{"env":"c"}`, true},
+               {"int bypass via secret", "", `{"num":"abc"}`, true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := ValidateTaskSurveyValues(vars, tt.env, tt.secret)
+                       if tt.wantErr {
+                               assert.Error(t, err)
+                       } else {
+                               assert.NoError(t, err)
+                       }
+               })
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/db/sql/template_vault.go 
new/semaphore-2.19.21/db/sql/template_vault.go
--- old/semaphore-2.19.18/db/sql/template_vault.go      2026-11-02 
21:03:04.000000000 +0100
+++ new/semaphore-2.19.21/db/sql/template_vault.go      2026-11-09 
22:49:37.000000000 +0100
@@ -64,8 +64,11 @@
                        }
                        vaultIDs = append(vaultIDs, strconv.Itoa(vaultId))
                } else {
-                       // Update existing vaults
-                       _, err = d.exec("update project__template_vault set 
project_id=?, template_id=?, vault_key_id=?, name=?, type=?, script=? where 
id=?", projectID, templateID, vault.VaultKeyID, vault.Name, vault.Type, 
vault.Script, vault.ID)
+                       // Update existing vaults. The WHERE clause is scoped 
to the target
+                       // project and template so a body-supplied vault ID 
belonging to
+                       // another project/template cannot be reparented or 
overwritten;
+                       // such an ID makes the update a no-op.
+                       _, err = d.exec("update project__template_vault set 
vault_key_id=?, name=?, type=?, script=? where id=? and project_id=? and 
template_id=?", vault.VaultKeyID, vault.Name, vault.Type, vault.Script, 
vault.ID, projectID, templateID)
                        vaultIDs = append(vaultIDs, strconv.Itoa(vault.ID))
                }
                if err != nil {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/semaphore-2.19.18/db/sql/template_vault_cross_project_test.go 
new/semaphore-2.19.21/db/sql/template_vault_cross_project_test.go
--- old/semaphore-2.19.18/db/sql/template_vault_cross_project_test.go   
1970-01-01 01:00:00.000000000 +0100
+++ new/semaphore-2.19.21/db/sql/template_vault_cross_project_test.go   
2026-11-09 22:49:37.000000000 +0100
@@ -0,0 +1,79 @@
+package sql
+
+import (
+       "testing"
+
+       "github.com/semaphoreui/semaphore/db"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// TestManagerCannotMoveForeignProjectVault reproduces GHSA-ffp6-w57q-758q.
+//
+// A user who can manage templates in project A creates a template there and, 
in
+// the Vaults array, supplies the numeric ID of a vault binding that belongs 
to a
+// template in project B. On the vulnerable code (v2.19.18) 
UpdateTemplateVaults
+// ran `UPDATE project__template_vault SET project_id=?, template_id=? ... 
WHERE
+// id=?`, selecting the row by id alone, so B's binding was reparented into A's
+// new template: B silently lost its binding.
+//
+// The test asserts the secure outcome: B keeps its binding and A's template 
does
+// not acquire B's row. It fails on the vulnerable code and passes once the 
UPDATE
+// WHERE clause is scoped to the authorized project and template.
+func TestManagerCannotMoveForeignProjectVault(t *testing.T) {
+       store := InitConfigCreateTestStore()
+
+       // Project B (the victim) with a template that owns a vault binding.
+       victimProjectID, victimRepoID := newTemplateTestProject(t, store)
+       victimScript := "victim-vault-client.py"
+       victimTemplate, err := store.CreateTemplate(db.Template{
+               ProjectID:    victimProjectID,
+               RepositoryID: victimRepoID,
+               Name:         "victim-template",
+               Playbook:     "site.yml",
+               Vaults: []db.TemplateVault{
+                       {Type: db.TemplateVaultScript, Script: &victimScript},
+               },
+       })
+       require.NoError(t, err)
+
+       // Read the persisted binding back from the store so we have its id on 
every
+       // release (older CreateTemplate did not populate returned vault ids).
+       victimVaultsBefore, err := store.GetTemplateVaults(victimProjectID, 
victimTemplate.ID)
+       require.NoError(t, err)
+       require.Len(t, victimVaultsBefore, 1)
+       foreignVaultID := victimVaultsBefore[0].ID
+       require.NotZero(t, foreignVaultID)
+
+       // Project A (the attacker) — a separate project the attacker manages.
+       attackerProjectID, attackerRepoID := newTemplateTestProject(t, store)
+
+       // The attack: create a template in A whose Vaults array references B's
+       // binding by id. type "script" clears vault_key_id, so no key from B is
+       // needed in the body.
+       attackScript := "attacker-vault-client.py"
+       attackerTemplate, err := store.CreateTemplate(db.Template{
+               ProjectID:    attackerProjectID,
+               RepositoryID: attackerRepoID,
+               Name:         "attacker-template",
+               Playbook:     "site.yml",
+               Vaults: []db.TemplateVault{
+                       {ID: foreignVaultID, Type: db.TemplateVaultScript, 
Script: &attackScript},
+               },
+       })
+       require.NoError(t, err)
+
+       // B must still own its binding.
+       victimVaults, err := store.GetTemplateVaults(victimProjectID, 
victimTemplate.ID)
+       require.NoError(t, err)
+       assert.Len(t, victimVaults, 1,
+               "victim project B lost its vault binding: it was reparented 
into attacker project A (GHSA-ffp6-w57q-758q)")
+
+       // A must not have stolen B's row.
+       attackerVaults, err := store.GetTemplateVaults(attackerProjectID, 
attackerTemplate.ID)
+       require.NoError(t, err)
+       for _, v := range attackerVaults {
+               assert.NotEqual(t, foreignVaultID, v.ID,
+                       "attacker template acquired the victim's vault-binding 
row (GHSA-ffp6-w57q-758q)")
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/semaphore-2.19.18/util/config_test.go 
new/semaphore-2.19.21/util/config_test.go
--- old/semaphore-2.19.18/util/config_test.go   2026-11-02 21:03:04.000000000 
+0100
+++ new/semaphore-2.19.21/util/config_test.go   2026-11-09 22:49:37.000000000 
+0100
@@ -551,6 +551,7 @@
        // assert := assert.New(t)
 
        Config = new(ConfigType)
+       Config.Auth = &AuthConfig{}
 
        testPort := ":3000"
        testDbDialect := DbDriverSQLite

++++++ semaphore.obsinfo ++++++
--- /var/tmp/diff_new_pack.R64EA1/_old  2026-11-10 14:45:12.519601199 +0100
+++ /var/tmp/diff_new_pack.R64EA1/_new  2026-11-10 14:45:12.522601324 +0100
@@ -1,5 +1,5 @@
 name: semaphore
-version: 2.19.18
-mtime: 1793649784
-commit: b621d1453a4eba54b5e162e2f572495d26944221
+version: 2.19.21
+mtime: 1794260977
+commit: 154ba40dec42033cca419e19ef57488cc1aa6554
 

++++++ vendor.tar.gz ++++++
/work/SRC/openSUSE:Factory/semaphore/vendor.tar.gz 
/work/SRC/openSUSE:Factory/.semaphore.new.3759290/vendor.tar.gz differ: char 
151, line 1

++++++ web-2.19.18.tar.gz -> web-2.19.21.tar.gz ++++++
/work/SRC/openSUSE:Factory/semaphore/web-2.19.18.tar.gz 
/work/SRC/openSUSE:Factory/.semaphore.new.3759290/web-2.19.21.tar.gz differ: 
char 15, line 1

Reply via email to