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

Reply via email to