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
