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-07-10 17:43:27 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/semaphore (Old) and /work/SRC/openSUSE:Factory/.semaphore.new.1991 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "semaphore" Fri Jul 10 17:43:27 2026 rev:51 rq:1364831 version:2.18.25 Changes: -------- --- /work/SRC/openSUSE:Factory/semaphore/semaphore.changes 2026-07-06 12:37:15.389672924 +0200 +++ /work/SRC/openSUSE:Factory/.semaphore.new.1991/semaphore.changes 2026-07-10 17:45:55.442630899 +0200 @@ -1,0 +2,14 @@ +Fri Jul 10 05:44:25 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 2.18.25: + * 7c3789c fix(runners): close connections +- Update to version 2.18.24: + Not released +- Update to version 2.18.23: + * 74d8dd5 feat(ui): placeholder for enterprise storages +- Update to version 2.18.22: + * debf7a0 feat(ui): return extra vars for secrets +- Update to version 2.18.21: + * feat: secure flag for session when https enabled + +------------------------------------------------------------------- Old: ---- semaphore-2.18.20.obscpio web-2.18.20.tar.gz New: ---- semaphore-2.18.25.obscpio web-2.18.25.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ semaphore.spec ++++++ --- /var/tmp/diff_new_pack.rjdaQ0/_old 2026-07-10 17:46:06.439006434 +0200 +++ /var/tmp/diff_new_pack.rjdaQ0/_new 2026-07-10 17:46:06.463007253 +0200 @@ -17,7 +17,7 @@ Name: semaphore -Version: 2.18.20 +Version: 2.18.25 Release: 0 Summary: Modern UI for Ansible License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.rjdaQ0/_old 2026-07-10 17:46:06.811019138 +0200 +++ /var/tmp/diff_new_pack.rjdaQ0/_new 2026-07-10 17:46:06.843020231 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/ansible-semaphore/semaphore.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v2.18.20</param> + <param name="revision">refs/tags/v2.18.25</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.rjdaQ0/_old 2026-07-10 17:46:06.963024329 +0200 +++ /var/tmp/diff_new_pack.rjdaQ0/_new 2026-07-10 17:46:07.011025968 +0200 @@ -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">5d87656a680600125fe78edec1f7a0d10484b52c</param></service></servicedata> + <param name="changesrevision">7c3789c890d7e92fb8ca2a63ad74babf460a13fa</param></service></servicedata> (No newline at EOF) ++++++ semaphore-2.18.20.obscpio -> semaphore-2.18.25.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/auth.go new/semaphore-2.18.25/api/auth.go --- old/semaphore-2.18.20/api/auth.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/auth.go 2026-07-09 13:57:28.000000000 +0200 @@ -3,6 +3,7 @@ import ( "errors" "net/http" + "net/url" "strings" "time" @@ -319,6 +320,93 @@ return } + next.ServeHTTP(w, r) + }) +} + +// isStateChangingMethod reports whether an HTTP method can modify server state +// and therefore requires CSRF protection. Safe methods (GET, HEAD, OPTIONS, +// TRACE) are excluded. +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +// requestOriginHost extracts the origin host (host[:port]) of the request from +// the Origin header, falling back to the Referer header. The boolean is false +// when neither header is present or parseable. +func requestOriginHost(r *http.Request) (string, bool) { + for _, header := range []string{"Origin", "Referer"} { + value := r.Header.Get(header) + if value == "" { + continue + } + + u, err := url.Parse(value) + if err != nil || u.Host == "" { + continue + } + + return u.Host, true + } + + return "", false +} + +// isSameOriginHost reports whether host belongs to Semaphore itself. Both the +// configured public web host and the host the request was addressed to are +// accepted, so reverse-proxy deployments keep working. +func isSameOriginHost(host string, r *http.Request) bool { + if host == r.Host { + return true + } + + if util.WebHostURL != nil && host == util.WebHostURL.Host { + return true + } + + return false +} + +// csrfProtectionMiddleware blocks cross-site state-changing requests that rely +// on the session cookie, providing defense-in-depth against CSRF on top of the +// SameSite=Lax session cookie. +// +// Requests authenticated with an API token (Authorization: bearer) are exempt: +// browsers never attach such tokens automatically, so token-based clients are +// not vulnerable to CSRF and must keep working without an Origin header. +// +// When neither Origin nor Referer is present (e.g. non-browser clients using a +// cookie), the request is allowed — the SameSite=Lax cookie already prevents a +// browser from sending the session cookie cross-site in that case. +func csrfProtectionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isStateChangingMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + + authHeader := strings.ToLower(r.Header.Get("authorization")) + if strings.Contains(authHeader, "bearer") { + next.ServeHTTP(w, r) + return + } + + if origin, ok := requestOriginHost(r); ok && !isSameOriginHost(origin, r) { + log.WithFields(log.Fields{ + "origin": origin, + "host": r.Host, + "path": r.URL.Path, + "method": r.Method, + }).Warn("Blocked cross-origin request (possible CSRF)") + helpers.WriteErrorStatus(w, "CROSS_ORIGIN_REQUEST_BLOCKED", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/auth_test.go new/semaphore-2.18.25/api/auth_test.go --- old/semaphore-2.18.20/api/auth_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/semaphore-2.18.25/api/auth_test.go 2026-07-09 13:57:28.000000000 +0200 @@ -0,0 +1,189 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/semaphoreui/semaphore/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsStateChangingMethod(t *testing.T) { + tests := []struct { + method string + expected bool + }{ + {http.MethodGet, false}, + {http.MethodHead, false}, + {http.MethodOptions, false}, + {http.MethodPost, true}, + {http.MethodPut, true}, + {http.MethodPatch, true}, + {http.MethodDelete, true}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + assert.Equal(t, tt.expected, isStateChangingMethod(tt.method)) + }) + } +} + +func TestRequestOriginHost(t *testing.T) { + t.Run("from Origin header", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/users/1/password", nil) + r.Header.Set("Origin", "https://semaphore.example.com") + + host, ok := requestOriginHost(r) + assert.True(t, ok) + assert.Equal(t, "semaphore.example.com", host) + }) + + t.Run("falls back to Referer", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/users/1/password", nil) + r.Header.Set("Referer", "https://semaphore.example.com/project/1") + + host, ok := requestOriginHost(r) + assert.True(t, ok) + assert.Equal(t, "semaphore.example.com", host) + }) + + t.Run("Origin takes precedence over Referer", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/users/1/password", nil) + r.Header.Set("Origin", "https://attacker.com") + r.Header.Set("Referer", "https://semaphore.example.com/") + + host, ok := requestOriginHost(r) + assert.True(t, ok) + assert.Equal(t, "attacker.com", host) + }) + + t.Run("no headers", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/users/1/password", nil) + + _, ok := requestOriginHost(r) + assert.False(t, ok) + }) +} + +// newRecordingHandler returns an http.Handler that records whether it was +// called, used to assert that the middleware did or did not pass the request +// through. +func newRecordingHandler(called *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *called = true + w.WriteHeader(http.StatusNoContent) + }) +} + +func TestCsrfProtectionMiddleware(t *testing.T) { + orig := util.WebHostURL + defer func() { util.WebHostURL = orig }() + + webHost, err := url.Parse("https://semaphore.example.com") + require.NoError(t, err) + util.WebHostURL = webHost + + tests := []struct { + name string + method string + host string + origin string + referer string + authHeader string + wantStatus int + wantForwPass bool + }{ + { + name: "safe method is always allowed", + method: http.MethodGet, + host: "semaphore.example.com", + origin: "https://attacker.com", + wantStatus: http.StatusNoContent, + wantForwPass: true, + }, + { + name: "same origin POST is allowed", + method: http.MethodPost, + host: "semaphore.example.com", + origin: "https://semaphore.example.com", + wantStatus: http.StatusNoContent, + wantForwPass: true, + }, + { + name: "cross origin POST is blocked", + method: http.MethodPost, + host: "semaphore.example.com", + origin: "https://attacker.com", + wantStatus: http.StatusForbidden, + wantForwPass: false, + }, + { + name: "cross origin DELETE is blocked", + method: http.MethodDelete, + host: "semaphore.example.com", + origin: "https://attacker.com:1337", + wantStatus: http.StatusForbidden, + wantForwPass: false, + }, + { + name: "cross origin via Referer is blocked", + method: http.MethodPost, + host: "semaphore.example.com", + referer: "https://attacker.com/evil", + wantStatus: http.StatusForbidden, + wantForwPass: false, + }, + { + name: "missing origin and referer is allowed", + method: http.MethodPost, + host: "semaphore.example.com", + wantStatus: http.StatusNoContent, + wantForwPass: true, + }, + { + name: "bearer token bypasses origin check", + method: http.MethodPost, + host: "semaphore.example.com", + origin: "https://attacker.com", + authHeader: "Bearer sometoken", + wantStatus: http.StatusNoContent, + wantForwPass: true, + }, + { + name: "origin matching request host is allowed", + method: http.MethodPost, + host: "internal-proxy:3000", + origin: "http://internal-proxy:3000", + wantStatus: http.StatusNoContent, + wantForwPass: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(tt.method, "/api/users/1/password", nil) + r.Host = tt.host + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + if tt.referer != "" { + r.Header.Set("Referer", tt.referer) + } + if tt.authHeader != "" { + r.Header.Set("Authorization", tt.authHeader) + } + + var forwarded bool + w := httptest.NewRecorder() + + csrfProtectionMiddleware(newRecordingHandler(&forwarded)).ServeHTTP(w, r) + + assert.Equal(t, tt.wantStatus, w.Code) + assert.Equal(t, tt.wantForwPass, forwarded) + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/login.go new/semaphore-2.18.25/api/login.go --- old/semaphore-2.18.20/api/login.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/login.go 2026-07-09 13:57:28.000000000 +0200 @@ -201,9 +201,23 @@ Value: encoded, Path: "/", HttpOnly: true, + // SameSite=Lax prevents the session cookie from being attached to + // cross-site POST requests, which mitigates CSRF (e.g. the password + // change endpoint). Top-level GET navigations still carry the cookie + // so following a link into Semaphore keeps the user logged in. + SameSite: http.SameSiteLaxMode, + // Secure is only enforced when Semaphore is served over HTTPS, so that + // it can still be used without TLS inside private networks. + Secure: isSecureWebHost(), }) } +// isSecureWebHost reports whether Semaphore's public web host uses HTTPS, in +// which case cookies should carry the Secure attribute. +func isSecureWebHost() bool { + return util.WebHostURL != nil && util.WebHostURL.Scheme == "https" +} + func loginByPassword(store db.Store, login string, password string) (user db.User, err error) { user, err = store.GetUserByLoginOrEmail(login, login) if err != nil { @@ -395,6 +409,8 @@ Expires: tz.Now().Add(24 * 7 * time.Hour * -1), Path: "/", HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecureWebHost(), }) w.WriteHeader(http.StatusNoContent) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/login_test.go new/semaphore-2.18.25/api/login_test.go --- old/semaphore-2.18.20/api/login_test.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/login_test.go 2026-07-09 13:57:28.000000000 +0200 @@ -5,10 +5,13 @@ "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "time" + "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseClaim(t *testing.T) { @@ -76,6 +79,35 @@ assert.Equal(t, "123456757343", res, "Result should match formatted ID") } +func TestIsSecureWebHost(t *testing.T) { + orig := util.WebHostURL + defer func() { util.WebHostURL = orig }() + + tests := []struct { + name string + webHost string + expected bool + }{ + {"https host is secure", "https://semaphore.example.com", true}, + {"http host is not secure", "http://semaphore.example.com:3000", false}, + {"nil host is not secure", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.webHost == "" { + util.WebHostURL = nil + } else { + u, err := url.Parse(tt.webHost) + require.NoError(t, err) + util.WebHostURL = u + } + + assert.Equal(t, tt.expected, isSecureWebHost()) + }) + } +} + func TestGenerateStateOauthCookie(t *testing.T) { w := httptest.NewRecorder() returnPath := "/dashboard" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/router.go new/semaphore-2.18.25/api/router.go --- old/semaphore-2.18.20/api/router.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/router.go 2026-07-09 13:57:28.000000000 +0200 @@ -181,7 +181,7 @@ authenticatedWS.Path("/ws").HandlerFunc(sockets.Handler).Methods("GET", "HEAD") authenticatedAPI := r.PathPrefix(webPath + "api").Subrouter() - authenticatedAPI.Use(StoreMiddleware, JSONMiddleware, authentication) + authenticatedAPI.Use(csrfProtectionMiddleware, StoreMiddleware, JSONMiddleware, authentication) authenticatedAPI.Path("/info").HandlerFunc(systemInfoController.GetSystemInfo).Methods("GET", "HEAD") @@ -256,21 +256,21 @@ tasksAPI.Path("/{task_id}").HandlerFunc(tasks.DeleteTask).Methods("DELETE") userUserAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() - userUserAPI.Use(readonlyUserMiddleware) + userUserAPI.Use(usersController.ReadonlyUserMiddleware) userUserAPI.Methods("GET", "HEAD").HandlerFunc(userController.GetUser) userAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() - userAPI.Use(getUserMiddleware) + userAPI.Use(usersController.GetUserMiddleware) userAPI.Methods("PUT").HandlerFunc(usersController.UpdateUser) - userAPI.Methods("DELETE").HandlerFunc(deleteUser) + userAPI.Methods("DELETE").HandlerFunc(usersController.DeleteUser) userPasswordAPI := authenticatedAPI.PathPrefix("/users/{user_id}").Subrouter() - userPasswordAPI.Use(getUserMiddleware) - userPasswordAPI.Path("/password").HandlerFunc(updateUserPassword).Methods("POST") - userPasswordAPI.Path("/2fas/totp").HandlerFunc(enableTotp).Methods("POST") - userPasswordAPI.Path("/2fas/totp/{totp_id}/qr").HandlerFunc(totpQr).Methods("GET") - userPasswordAPI.Path("/2fas/totp/{totp_id}").HandlerFunc(disableTotp).Methods("DELETE") + userPasswordAPI.Use(usersController.GetUserMiddleware) + userPasswordAPI.Path("/password").HandlerFunc(usersController.UpdateUserPassword).Methods("POST") + userPasswordAPI.Path("/2fas/totp").HandlerFunc(usersController.EnableTotp).Methods("POST") + userPasswordAPI.Path("/2fas/totp/{totp_id}/qr").HandlerFunc(usersController.TotpQr).Methods("GET") + userPasswordAPI.Path("/2fas/totp/{totp_id}").HandlerFunc(usersController.DisableTotp).Methods("DELETE") projectGet := authenticatedAPI.Path("/project/{project_id}").Subrouter() projectGet.Use(projects.ProjectMiddleware) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/user_options_test.go new/semaphore-2.18.25/api/user_options_test.go --- old/semaphore-2.18.20/api/user_options_test.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/user_options_test.go 2026-07-09 13:57:28.000000000 +0200 @@ -130,7 +130,7 @@ r = helpers.SetContextValue(r, "_user", target) w := httptest.NewRecorder() - deleteUser(w, r) + NewUsersController(nil).DeleteUser(w, r) assert.Equal(t, http.StatusNoContent, w.Code) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/api/users.go new/semaphore-2.18.25/api/users.go --- old/semaphore-2.18.20/api/users.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/api/users.go 2026-07-09 13:57:28.000000000 +0200 @@ -18,11 +18,13 @@ type UsersController struct { subscriptionService pro_interfaces.SubscriptionService + log *log.Entry } func NewUsersController(subscriptionService pro_interfaces.SubscriptionService) *UsersController { return &UsersController{ subscriptionService: subscriptionService, + log: log.WithField("context", "api.users"), } } @@ -67,7 +69,7 @@ editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin { - log.Warn(editor.Username + " is not permitted to create users") + c.log.WithField("editor", editor.Username).Debug("Not permitted to create users") w.WriteHeader(http.StatusUnauthorized) return } @@ -76,6 +78,7 @@ ok, err := c.subscriptionService.CanAddProUser() if err != nil { + c.log.WithError(err).Error("Failed to check Pro user limit") w.WriteHeader(http.StatusInternalServerError) return } @@ -100,14 +103,14 @@ } if err != nil { - log.Warn(editor.Username + " is not created: " + err.Error()) + c.log.WithError(err).WithField("username", user.Username).Error("Failed to create user") w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusCreated, newUser) } -func readonlyUserMiddleware(next http.Handler) http.Handler { +func (c *UsersController) ReadonlyUserMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, err := helpers.GetIntParam("user_id", w, r) @@ -137,7 +140,7 @@ }) } -func getUserMiddleware(next http.Handler) http.Handler { +func (c *UsersController) GetUserMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, err := helpers.GetIntParam("user_id", w, r) @@ -155,7 +158,10 @@ editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin && editor.ID != user.ID { - log.Warn(editor.Username + " is not permitted to edit users") + c.log.WithFields(log.Fields{ + "editor": editor.Username, + "user_id": user.ID, + }).Debug("Not permitted to access another user") w.WriteHeader(http.StatusUnauthorized) return } @@ -175,7 +181,10 @@ } if !editor.Admin && (user.Pro && !targetUser.Pro) { - log.Warn(editor.Username + " is not permitted to mark users as Pro") + c.log.WithFields(log.Fields{ + "editor": editor.Username, + "user_id": targetUser.ID, + }).Debug("Not permitted to mark users as Pro") w.WriteHeader(http.StatusUnauthorized) return } @@ -184,6 +193,7 @@ ok, err := c.subscriptionService.CanAddProUser() if err != nil { + c.log.WithError(err).Error("Failed to check Pro user limit") w.WriteHeader(http.StatusInternalServerError) return } @@ -199,26 +209,29 @@ } if !editor.Admin && editor.ID != targetUser.ID { - log.Warn(editor.Username + " is not permitted to edit users") + c.log.WithFields(log.Fields{ + "editor": editor.Username, + "user_id": targetUser.ID, + }).Debug("Not permitted to update another user") w.WriteHeader(http.StatusUnauthorized) return } if editor.ID == targetUser.ID && targetUser.Admin != user.Admin { - log.Warn("User can't edit his own role") + c.log.WithField("editor", editor.Username).Debug("Not permitted to change own admin status") w.WriteHeader(http.StatusUnauthorized) return } if targetUser.External && targetUser.Username != user.Username { - log.Warn("Username is not editable for external users") + c.log.WithField("user_id", targetUser.ID).Debug("Username is not editable for external users") w.WriteHeader(http.StatusBadRequest) return } user.ID = targetUser.ID if err := helpers.Store(r).UpdateUser(user); err != nil { - log.Error(err.Error()) + c.log.WithError(err).WithField("user_id", targetUser.ID).Error("Failed to update user") w.WriteHeader(http.StatusBadRequest) return } @@ -226,7 +239,7 @@ w.WriteHeader(http.StatusNoContent) } -func updateUserPassword(w http.ResponseWriter, r *http.Request) { +func (c *UsersController) UpdateUserPassword(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) editor := helpers.GetFromContext(r, "user").(*db.User) @@ -235,13 +248,16 @@ } if !editor.Admin && editor.ID != user.ID { - log.Warn(editor.Username + " is not permitted to edit users") + c.log.WithFields(log.Fields{ + "editor": editor.Username, + "user_id": user.ID, + }).Debug("Not permitted to change another user's password") w.WriteHeader(http.StatusUnauthorized) return } if user.External { - log.Warn("Password is not editable for external users") + c.log.WithField("user_id", user.ID).Debug("Password is not editable for external users") w.WriteHeader(http.StatusBadRequest) return } @@ -251,7 +267,7 @@ } if err := helpers.Store(r).SetUserPassword(user.ID, pwd.Pwd); err != nil { - util.LogWarning(err) + c.log.WithError(err).WithField("user_id", user.ID).Error("Failed to set user password") w.WriteHeader(http.StatusInternalServerError) return } @@ -259,29 +275,33 @@ w.WriteHeader(http.StatusNoContent) } -func deleteUser(w http.ResponseWriter, r *http.Request) { +func (c *UsersController) DeleteUser(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin && editor.ID != user.ID { - log.Warn(editor.Username + " is not permitted to delete users") + c.log.WithFields(log.Fields{ + "editor": editor.Username, + "user_id": user.ID, + }).Debug("Not permitted to delete another user") w.WriteHeader(http.StatusUnauthorized) return } if err := helpers.Store(r).DeleteUser(user.ID); err != nil { + c.log.WithError(err).WithField("user_id", user.ID).Error("Failed to delete user") w.WriteHeader(http.StatusInternalServerError) return } if err := helpers.Store(r).DeleteOptions(fmt.Sprintf("user%d", user.ID)); err != nil { - log.WithError(err).Warn("can not delete options of removed user") + c.log.WithError(err).WithField("user_id", user.ID).Error("Failed to delete options of removed user") } w.WriteHeader(http.StatusNoContent) } -func totpQr(w http.ResponseWriter, r *http.Request) { +func (c *UsersController) TotpQr(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if user.Totp == nil { @@ -313,7 +333,7 @@ _, err = w.Write(pngBytes) } -func enableTotp(w http.ResponseWriter, r *http.Request) { +func (c *UsersController) EnableTotp(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if !util.Config.Mfa.Totp.Enabled { @@ -332,6 +352,7 @@ }) if err != nil { + c.log.WithError(err).WithFields(log.Fields{"user_id": user.ID}).Error("Failed to generate TOTP key") http.Error(w, "Error generating key", http.StatusInternalServerError) return } @@ -357,7 +378,7 @@ helpers.WriteJSON(w, http.StatusOK, newTotp) } -func disableTotp(w http.ResponseWriter, r *http.Request) { +func (c *UsersController) DisableTotp(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if user.Totp == nil { helpers.WriteErrorStatus(w, "TOTP not enabled", http.StatusBadRequest) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/pro_interfaces/featues.go new/semaphore-2.18.25/pro_interfaces/featues.go --- old/semaphore-2.18.20/pro_interfaces/featues.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/pro_interfaces/featues.go 2026-07-09 13:57:28.000000000 +0200 @@ -1,11 +1,12 @@ package pro_interfaces type Features struct { - ProjectRunners bool `json:"project_runners"` - TerraformBackend bool `json:"terraform_backend"` - TaskSummary bool `json:"task_summary"` - SecretStorages bool `json:"secret_storages"` - SecretStorageManagement bool `json:"secret_storage_management"` - CustomRolesManagement bool `json:"custom_roles_management"` - HighAvailability bool `json:"high_availability"` + ProjectRunners bool `json:"project_runners"` + TerraformBackend bool `json:"terraform_backend"` + TaskSummary bool `json:"task_summary"` + SecretStorages bool `json:"secret_storages"` + SecretStorageManagement bool `json:"secret_storage_management"` + SecretStorageManagementEx bool `json:"secret_storage_management_ex"` + CustomRolesManagement bool `json:"custom_roles_management"` + HighAvailability bool `json:"high_availability"` } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/services/runners/job_pool.go new/semaphore-2.18.25/services/runners/job_pool.go --- old/semaphore-2.18.20/services/runners/job_pool.go 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/services/runners/job_pool.go 2026-07-09 13:57:28.000000000 +0200 @@ -91,6 +91,12 @@ processing int32 keyInstaller db_lib.AccessKeyInstaller + + // client is the shared HTTP client for all runner→server requests. It is + // created once so the transport's keep-alive pool reuses connections — + // creating a client per request leaks one ESTABLISHED connection per poll + // cycle (~2/sec) until the runner exhausts ephemeral ports (issue #3941). + client *http.Client } func NewJobPool(keyInstaller db_lib.AccessKeyInstaller) *JobPool { @@ -99,6 +105,7 @@ queue: make([]*job, 0), processing: 0, keyInstaller: keyInstaller, + client: newHTTPClient(), } } @@ -140,8 +147,6 @@ return fmt.Errorf("runner is not registered") } - client := newHTTPClient() - url := util.Config.WebHost + "/api/internal/runners" req, err := http.NewRequest("DELETE", url, nil) @@ -149,10 +154,11 @@ return } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return } + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode >= 400 && resp.StatusCode != 404 { err = fmt.Errorf("encountered error while unregistering runner; server returned code %d", resp.StatusCode) @@ -277,8 +283,6 @@ logger := JobLogger{Context: "sending_progress"} - client := newHTTPClient() - url := util.Config.WebHost + "/api/internal/runners" body := RunnerProgress{ @@ -310,7 +314,7 @@ req.Header.Set("X-Runner-Token", util.Config.Runner.Token) - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { logger.ActionError(err, "send request", "the server returned error") return @@ -391,8 +395,6 @@ return } - client := newHTTPClient() - url := util.Config.WebHost + "/api/internal/runners" jsonBytes, err := json.Marshal(RunnerRegistration{ @@ -417,13 +419,15 @@ return } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { logger.ActionError(err, "send request", "unexpected error") return } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != 200 { logger.ActionError(fmt.Errorf("invalid status code"), "send request", p.getResponseErrorMessage(resp)) return @@ -481,8 +485,6 @@ } } - defer resp.Body.Close() //nolint:errcheck - ok = true return } @@ -546,8 +548,6 @@ return } - client := newHTTPClient() - url := util.Config.WebHost + "/api/internal/runners" req, err := http.NewRequest("GET", url, nil) @@ -559,7 +559,9 @@ req.Header.Set("X-Runner-Token", util.Config.Runner.Token) - resp, err := client.Do(req) + resp, err := p.client.Do(req) + + defer resp.Body.Close() //nolint:errcheck if err != nil { logger.ActionError(err, "send request", "unexpected error") @@ -572,8 +574,6 @@ return } - defer resp.Body.Close() //nolint:errcheck - body, err := io.ReadAll(resp.Body) if err != nil { logger.ActionError(err, "read response body", "can not read server's response body") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/web/src/components/EnvironmentForm.vue new/semaphore-2.18.25/web/src/components/EnvironmentForm.vue --- old/semaphore-2.18.20/web/src/components/EnvironmentForm.vue 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/web/src/components/EnvironmentForm.vue 2026-07-09 13:57:28.000000000 +0200 @@ -269,7 +269,7 @@ </v-card> </v-dialog> - <div v-if="secrets.filter((s) => !s.remove && s.type === 'var').length > 0"> + <div> <v-subheader class="px-0"> {{ $t('extraVariables') }} <v-tooltip v-if="needHelp" bottom color="black" open-delay="300" max-width="400"> @@ -288,9 +288,12 @@ </v-btn> </v-subheader> - <v-alert type="error" text> - Passing secrets using this method is not secure. This feature will be removed in version - 2.19. + <v-alert + color="warning" + text + v-if="secrets.filter((s) => !s.remove && s.type === 'var').length > 0" + > + Secrets passed this way may appear in plain text in Ansible logs. </v-alert> <v-data-table diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.18.20/web/src/views/project/SecretStorages.vue new/semaphore-2.18.25/web/src/views/project/SecretStorages.vue --- old/semaphore-2.18.20/web/src/views/project/SecretStorages.vue 2026-07-05 21:21:33.000000000 +0200 +++ new/semaphore-2.18.25/web/src/views/project/SecretStorages.vue 2026-07-09 13:57:28.000000000 +0200 @@ -67,47 +67,74 @@ <v-list-item-title>Hashicorp Vault</v-list-item-title> </v-list-item> - <v-list-item - link - @click=" - editItem('new'); - itemType = 'aws_sm'; - " - :disabled="!features.secret_storage_management" + <div + :class="{ + SecretStoragesEnterpriseMenu: + features.secret_storage_management && !features.secret_storage_management_ex, + }" + :style="{ + backgroundColor: + features.secret_storage_management && !features.secret_storage_management_ex + ? $vuetify.theme.dark + ? '#3f3f3f' + : '#f0f0f0' + : '', + }" > - <v-list-item-icon> - <v-icon>$vuetify.icons.aws_sm</v-icon> - </v-list-item-icon> - <v-list-item-title>AWS Secrets Manager</v-list-item-title> - </v-list-item> - - <v-list-item - link - @click=" - editItem('new'); - itemType = 'azure_kv'; - " - :disabled="!features.secret_storage_management" - > - <v-list-item-icon> - <v-icon>$vuetify.icons.azure_kv</v-icon> - </v-list-item-icon> - <v-list-item-title>Azure Key Vault</v-list-item-title> - </v-list-item> - - <v-list-item - link - @click=" - editItem('new'); - itemType = 'dvls'; - " - :disabled="!features.secret_storage_management" - > - <v-list-item-icon> - <v-icon>$vuetify.icons.dvls</v-icon> - </v-list-item-icon> - <v-list-item-title>Devolutions Server</v-list-item-title> - </v-list-item> + <v-list-item + link + @click=" + editItem('new'); + itemType = 'aws_sm'; + " + :disabled="!features.secret_storage_management_ex" + > + <v-list-item-icon> + <v-icon>$vuetify.icons.aws_sm</v-icon> + </v-list-item-icon> + <v-list-item-title>AWS Secrets Manager</v-list-item-title> + </v-list-item> + + <v-list-item + link + @click=" + editItem('new'); + itemType = 'azure_kv'; + " + :disabled="!features.secret_storage_management_ex" + > + <v-list-item-icon> + <v-icon>$vuetify.icons.azure_kv</v-icon> + </v-list-item-icon> + <v-list-item-title>Azure Key Vault</v-list-item-title> + </v-list-item> + + <v-list-item + link + @click=" + editItem('new'); + itemType = 'dvls'; + " + :disabled="!features.secret_storage_management_ex" + > + <v-list-item-icon> + <v-icon>$vuetify.icons.dvls</v-icon> + </v-list-item-icon> + <v-list-item-title>Devolutions Server</v-list-item-title> + </v-list-item> + + <a + v-if="features.secret_storage_management && !features.secret_storage_management_ex" + class="SecretStoragesEnterpriseMenu__overlay" + href="https://semaphoreui.com/enterprise" + target="_blank" + > + <div class="SecretStoragesEnterpriseMenu__button"> + Enterprise + <v-icon color="white" small class="ml-1">mdi-arrow-right</v-icon> + </div> + </a> + </div> </v-list> </v-menu> </v-toolbar> @@ -195,7 +222,41 @@ </div> </template> -<style scoped lang="scss"></style> +<style scoped lang="scss"> +.SecretStoragesEnterpriseMenu { + position: relative; + cursor: not-allowed; +} + +.SecretStoragesEnterpriseMenu__overlay { + text-decoration: none !important; + transition: 0.3s; + z-index: 1; + backdrop-filter: blur(5px); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + justify-content: center; + align-content: center; + flex-wrap: wrap; + opacity: 0; + &:hover { + opacity: 1; + } +} + +.SecretStoragesEnterpriseMenu__button { + background: orange; + color: white; + font-weight: bold; + border-radius: 100px; + padding: 6px 16px; +} +</style> <script> import axios from 'axios'; ++++++ semaphore.obsinfo ++++++ --- /var/tmp/diff_new_pack.rjdaQ0/_old 2026-07-10 17:46:10.495144953 +0200 +++ /var/tmp/diff_new_pack.rjdaQ0/_new 2026-07-10 17:46:10.531146183 +0200 @@ -1,5 +1,5 @@ name: semaphore -version: 2.18.20 -mtime: 1783279293 -commit: 5d87656a680600125fe78edec1f7a0d10484b52c +version: 2.18.25 +mtime: 1783598248 +commit: 7c3789c890d7e92fb8ca2a63ad74babf460a13fa ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/semaphore/vendor.tar.gz /work/SRC/openSUSE:Factory/.semaphore.new.1991/vendor.tar.gz differ: char 151, line 2 ++++++ web-2.18.20.tar.gz -> web-2.18.25.tar.gz ++++++ /work/SRC/openSUSE:Factory/semaphore/web-2.18.20.tar.gz /work/SRC/openSUSE:Factory/.semaphore.new.1991/web-2.18.25.tar.gz differ: char 31, line 1
