High severity8.4OSV Advisory· Published Apr 11, 2025· Updated Apr 15, 2026
CVE-2025-23389
CVE-2025-23389
Description
A Improper Access Control vulnerability in SUSE rancher allows a local user to impersonate other identities through SAML Authentication on first login. This issue affects rancher: from 2.8.0 before 2.8.13, from 2.9.0 before 2.9.7, from 2.10.0 before 2.10.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/rancher/rancherGo | >= 2.8.0, < 2.8.13 | 2.8.13 |
github.com/rancher/rancherGo | >= 2.9.0, < 2.9.7 | 2.9.7 |
github.com/rancher/rancherGo | >= 2.10.0, < 2.10.3 | 2.10.3 |
Affected products
1Patches
6a717664d9c19cecf1d1e99c7ecc87e7d86e94b885322eaf9[v2.8] Expand SAML test coverage (#49031)
6 files changed · +244 −12
pkg/auth/providers/saml/saml_actions.go+1 −2 modified@@ -60,10 +60,9 @@ func (s *Provider) testAndEnable(actionName string, action *types.Action, reques logrus.Debugf("SAML [testAndEnable]: Setting clientState for SAML service provider %v", s.name) finalRedirectURL := samlLogin.FinalRedirectURL - provider.clientState.SetState(request.Response, request.Request, "Rancher_UserID", provider.userMGR.GetUser(request)) provider.clientState.SetState(request.Response, request.Request, "Rancher_FinalRedirectURL", finalRedirectURL) provider.clientState.SetState(request.Response, request.Request, "Rancher_Action", testAndEnableAction) - idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request) + idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request, provider.userMGR.GetUser(request)) if err != nil { return err }
pkg/auth/providers/saml/saml_client.go+38 −8 modified@@ -18,6 +18,7 @@ import ( "time" "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" "github.com/gorilla/mux" responsewriter "github.com/rancher/apiserver/pkg/middleware" v32 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" @@ -267,20 +268,26 @@ func (s *Provider) getSamlPrincipals(config *v32.SamlConfig, samlData map[string func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) { var groupPrincipals []v3.Principal var userPrincipal v3.Principal - - if relayState := r.Form.Get("RelayState"); relayState != "" { - // delete the cookie - s.clientState.DeleteState(w, r, relayState) - } - + var userID string redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") rancherAction := s.clientState.GetState(r, "Rancher_Action") if rancherAction == loginAction { redirectURL += "/login?" } else if rancherAction == testAndEnableAction { + var err error + userID, err = s.getUserIdFromRelayStateCookie(r) + if err != nil { + log.Errorf("SAML: Error getting state from cookie: %v", err) + http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) + return + } // the first query param is config=saml_provider_name set by UI redirectURL += "&" } + if relayState := r.Form.Get("RelayState"); relayState != "" { + // delete the cookie + s.clientState.DeleteState(w, r, relayState) + } samlData := make(map[string][]string) @@ -324,7 +331,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a return } - userID := s.clientState.GetState(r, "Rancher_UserID") if userID != "" && rancherAction == testAndEnableAction { user, err := s.userMGR.SetPrincipalOnCurrentUserByUserID(userID, userPrincipal) if err != nil && user == nil { @@ -354,7 +360,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) } // delete the cookies - s.clientState.DeleteState(w, r, "Rancher_UserID") s.clientState.DeleteState(w, r, "Rancher_Action") redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") s.clientState.DeleteState(w, r, "Rancher_FinalRedirectURL") @@ -495,3 +500,28 @@ func (s *Provider) setRancherToken(w http.ResponseWriter, tokenMGR *tokens.Manag return nil } + +func (s *Provider) getUserIdFromRelayStateCookie(r *http.Request) (string, error) { + userID := "" + // The state is stored in a cookie, which has the relay state as the key and a JWT token containing the userID as the value + if relayState := r.Form.Get("RelayState"); relayState != "" { + relayStateCookie := s.clientState.GetState(r, relayState) + jwtParser := jwt.Parser{ + ValidMethods: []string{jwt.SigningMethodHS256.Name}, + } + token, err := jwtParser.Parse(relayStateCookie, func(t *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(s.serviceProvider.Key) + return secretBlock, nil + }) + if err != nil { + return "", fmt.Errorf("error parsing relay state token: %w", err) + } + if !token.Valid { + return "", fmt.Errorf("invalid token") + } + claims := token.Claims.(jwt.MapClaims) + userID, _ = claims[rancherUserID].(string) + } + + return userID, nil +}
pkg/auth/providers/saml/saml_client_test.go+142 −0 added@@ -0,0 +1,142 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetUserIdFromRelayState(t *testing.T) { + host := "http://www.rancher.com/" + relayStateValue := "mockValue" + mockUserID := "u-neuwrd" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + tests := map[string]struct { + createRequest func() *http.Request + wantUserID string + wantErr string + }{ + "valid userId": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantUserID: mockUserID, + }, + "userId not present": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + }, + "relay state not present": { + createRequest: func() *http.Request { + return httptest.NewRequest(http.MethodPost, host, nil) + }, + }, + "invalid token": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=wrongToken"}, + } + + return req + }, + wantErr: "error parsing relay state token", + }, + "state signed with a different key": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + anotherKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + secretBlock := x509.MarshalPKCS1PrivateKey(anotherKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantErr: "signature is invalid", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + } + p := Provider{ + clientState: cookieStore, + serviceProvider: &saml.ServiceProvider{ + Key: privateKey, + }, + } + + userID, err := p.getUserIdFromRelayStateCookie(test.createRequest()) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + } else { + assert.NoError(t, err) + } + assert.Equal(t, test.wantUserID, userID) + }) + } +}
pkg/auth/providers/saml/saml_handlers.go+6 −1 modified@@ -13,6 +13,8 @@ import ( log "github.com/sirupsen/logrus" ) +const rancherUserID = "rancherUserID" + // ServeHTTP is the handler for /saml/metadata and /saml/acs endpoints func (s *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { serviceProvider := s.serviceProvider @@ -68,7 +70,7 @@ func (s *Provider) getPossibleRequestIDs(r *http.Request) []string { } // HandleSamlLogin is the endpoint for /saml/login endpoint -func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (string, error) { +func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request, userID string) (string, error) { serviceProvider := s.serviceProvider if r.URL.Path == serviceProvider.AcsURL.Path { return "", fmt.Errorf("don't wrap Middleware with RequireAccount") @@ -92,6 +94,9 @@ func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (stri claims := state.Claims.(jwt.MapClaims) claims["id"] = req.ID claims["uri"] = r.URL.String() + if userID != "" { + claims[rancherUserID] = userID + } signedState, err := state.SignedString(secretBlock) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError)
pkg/auth/providers/saml/saml_handlers_test.go+55 −0 added@@ -0,0 +1,55 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestHandleSamlLoginAddsRancherUserIdToCookie(t *testing.T) { + host := "http://www.rancher.com/" + mockUserId := "u-sidfes" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + serviceProvider := &saml.ServiceProvider{ + Key: privateKey, + IDPMetadata: &saml.EntityDescriptor{}, + } + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + ServiceProvider: serviceProvider, + } + p := Provider{ + serviceProvider: serviceProvider, + clientState: cookieStore, + } + w := httptest.NewRecorder() + + urlParams, err := p.HandleSamlLogin(w, httptest.NewRequest(http.MethodPost, host, nil), mockUserId) + + assert.NoError(t, err) + values, err := url.ParseQuery(urlParams) + assert.NoError(t, err) + relayState := values.Get("RelayState") + // extract token from cookies. The key of the cookie is the relay state string, and the value is the token + // e.g saml_DY3XlRnQsITgTsegiY-QuB38OsawU64uNZE4Q5iYCWIZOfz9YO6IYvUS=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImlkLTNkY2ViYTQ2MWE0Njg2YzRkYWEyNTZkYjI1YjZmMWFjNWE0YWY2Y2MiLCJyYW5jaGVyVXNlcklkIjoidS1zaWRmZXMiLCJ1cmkiOiJodHRwOi8vd3d3LnJhbmNoZXIuY29tLyJ9.oeUMC0d6FyNt2WlrgxsUjf4QQPIcjjpugULiQ87ep4M; Path=/; Max-Age=90; HttpOnly + cookie, err := http.ParseSetCookie(w.Header().Get("Set-Cookie")) + assert.NoError(t, err) + assert.Equal(t, "saml_"+relayState, cookie.Name) + token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(serviceProvider.Key) + return secretBlock, nil + }) + assert.NoError(t, err) + claims, _ := token.Claims.(jwt.MapClaims) + assert.Equal(t, mockUserId, claims[rancherUserID]) +}
pkg/auth/providers/saml/saml_provider.go+2 −1 modified@@ -127,7 +127,8 @@ func PerformSamlLogin(name string, apiContext *types.APIContext, input interface provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_RequestID", login.RequestID) provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_ResponseType", login.ResponseType) - idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request) + // userID is not needed for login. It's only needed for testAndEnable + idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request, "") if err != nil { return err }
cda77b743788Merge pull request #49030 from raulcabello/saml-tests-2.9
6 files changed · +244 −12
pkg/auth/providers/saml/saml_actions.go+1 −2 modified@@ -60,10 +60,9 @@ func (s *Provider) testAndEnable(actionName string, action *types.Action, reques logrus.Debugf("SAML [testAndEnable]: Setting clientState for SAML service provider %v", s.name) finalRedirectURL := samlLogin.FinalRedirectURL - provider.clientState.SetState(request.Response, request.Request, "Rancher_UserID", provider.userMGR.GetUser(request)) provider.clientState.SetState(request.Response, request.Request, "Rancher_FinalRedirectURL", finalRedirectURL) provider.clientState.SetState(request.Response, request.Request, "Rancher_Action", testAndEnableAction) - idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request) + idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request, provider.userMGR.GetUser(request)) if err != nil { return err }
pkg/auth/providers/saml/saml_client.go+38 −8 modified@@ -18,6 +18,7 @@ import ( "time" "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" "github.com/gorilla/mux" responsewriter "github.com/rancher/apiserver/pkg/middleware" v32 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" @@ -267,20 +268,26 @@ func (s *Provider) getSamlPrincipals(config *v32.SamlConfig, samlData map[string func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) { var groupPrincipals []v3.Principal var userPrincipal v3.Principal - - if relayState := r.Form.Get("RelayState"); relayState != "" { - // delete the cookie - s.clientState.DeleteState(w, r, relayState) - } - + var userID string redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") rancherAction := s.clientState.GetState(r, "Rancher_Action") if rancherAction == loginAction { redirectURL += "/login?" } else if rancherAction == testAndEnableAction { + var err error + userID, err = s.getUserIdFromRelayStateCookie(r) + if err != nil { + log.Errorf("SAML: Error getting state from cookie: %v", err) + http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) + return + } // the first query param is config=saml_provider_name set by UI redirectURL += "&" } + if relayState := r.Form.Get("RelayState"); relayState != "" { + // delete the cookie + s.clientState.DeleteState(w, r, relayState) + } samlData := make(map[string][]string) @@ -324,7 +331,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a return } - userID := s.clientState.GetState(r, "Rancher_UserID") if userID != "" && rancherAction == testAndEnableAction { user, err := s.userMGR.SetPrincipalOnCurrentUserByUserID(userID, userPrincipal) if err != nil && user == nil { @@ -354,7 +360,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) } // delete the cookies - s.clientState.DeleteState(w, r, "Rancher_UserID") s.clientState.DeleteState(w, r, "Rancher_Action") redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") s.clientState.DeleteState(w, r, "Rancher_FinalRedirectURL") @@ -495,3 +500,28 @@ func (s *Provider) setRancherToken(w http.ResponseWriter, tokenMGR *tokens.Manag return nil } + +func (s *Provider) getUserIdFromRelayStateCookie(r *http.Request) (string, error) { + userID := "" + // The state is stored in a cookie, which has the relay state as the key and a JWT token containing the userID as the value + if relayState := r.Form.Get("RelayState"); relayState != "" { + relayStateCookie := s.clientState.GetState(r, relayState) + jwtParser := jwt.Parser{ + ValidMethods: []string{jwt.SigningMethodHS256.Name}, + } + token, err := jwtParser.Parse(relayStateCookie, func(t *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(s.serviceProvider.Key) + return secretBlock, nil + }) + if err != nil { + return "", fmt.Errorf("error parsing relay state token: %w", err) + } + if !token.Valid { + return "", fmt.Errorf("invalid token") + } + claims := token.Claims.(jwt.MapClaims) + userID, _ = claims[rancherUserID].(string) + } + + return userID, nil +}
pkg/auth/providers/saml/saml_client_test.go+142 −0 added@@ -0,0 +1,142 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetUserIdFromRelayState(t *testing.T) { + host := "http://www.rancher.com/" + relayStateValue := "mockValue" + mockUserID := "u-neuwrd" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + tests := map[string]struct { + createRequest func() *http.Request + wantUserID string + wantErr string + }{ + "valid userId": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantUserID: mockUserID, + }, + "userId not present": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + }, + "relay state not present": { + createRequest: func() *http.Request { + return httptest.NewRequest(http.MethodPost, host, nil) + }, + }, + "invalid token": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=wrongToken"}, + } + + return req + }, + wantErr: "error parsing relay state token", + }, + "state signed with a different key": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + anotherKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + secretBlock := x509.MarshalPKCS1PrivateKey(anotherKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantErr: "signature is invalid", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + } + p := Provider{ + clientState: cookieStore, + serviceProvider: &saml.ServiceProvider{ + Key: privateKey, + }, + } + + userID, err := p.getUserIdFromRelayStateCookie(test.createRequest()) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + } else { + assert.NoError(t, err) + } + assert.Equal(t, test.wantUserID, userID) + }) + } +}
pkg/auth/providers/saml/saml_handlers.go+6 −1 modified@@ -13,6 +13,8 @@ import ( log "github.com/sirupsen/logrus" ) +const rancherUserID = "rancherUserID" + // ServeHTTP is the handler for /saml/metadata and /saml/acs endpoints func (s *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { serviceProvider := s.serviceProvider @@ -68,7 +70,7 @@ func (s *Provider) getPossibleRequestIDs(r *http.Request) []string { } // HandleSamlLogin is the endpoint for /saml/login endpoint -func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (string, error) { +func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request, userID string) (string, error) { serviceProvider := s.serviceProvider if r.URL.Path == serviceProvider.AcsURL.Path { return "", fmt.Errorf("don't wrap Middleware with RequireAccount") @@ -92,6 +94,9 @@ func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (stri claims := state.Claims.(jwt.MapClaims) claims["id"] = req.ID claims["uri"] = r.URL.String() + if userID != "" { + claims[rancherUserID] = userID + } signedState, err := state.SignedString(secretBlock) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError)
pkg/auth/providers/saml/saml_handlers_test.go+55 −0 added@@ -0,0 +1,55 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestHandleSamlLoginAddsRancherUserIdToCookie(t *testing.T) { + host := "http://www.rancher.com/" + mockUserId := "u-sidfes" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + serviceProvider := &saml.ServiceProvider{ + Key: privateKey, + IDPMetadata: &saml.EntityDescriptor{}, + } + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + ServiceProvider: serviceProvider, + } + p := Provider{ + serviceProvider: serviceProvider, + clientState: cookieStore, + } + w := httptest.NewRecorder() + + urlParams, err := p.HandleSamlLogin(w, httptest.NewRequest(http.MethodPost, host, nil), mockUserId) + + assert.NoError(t, err) + values, err := url.ParseQuery(urlParams) + assert.NoError(t, err) + relayState := values.Get("RelayState") + // extract token from cookies. The key of the cookie is the relay state string, and the value is the token + // e.g saml_DY3XlRnQsITgTsegiY-QuB38OsawU64uNZE4Q5iYCWIZOfz9YO6IYvUS=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImlkLTNkY2ViYTQ2MWE0Njg2YzRkYWEyNTZkYjI1YjZmMWFjNWE0YWY2Y2MiLCJyYW5jaGVyVXNlcklkIjoidS1zaWRmZXMiLCJ1cmkiOiJodHRwOi8vd3d3LnJhbmNoZXIuY29tLyJ9.oeUMC0d6FyNt2WlrgxsUjf4QQPIcjjpugULiQ87ep4M; Path=/; Max-Age=90; HttpOnly + cookie, err := http.ParseSetCookie(w.Header().Get("Set-Cookie")) + assert.NoError(t, err) + assert.Equal(t, "saml_"+relayState, cookie.Name) + token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(serviceProvider.Key) + return secretBlock, nil + }) + assert.NoError(t, err) + claims, _ := token.Claims.(jwt.MapClaims) + assert.Equal(t, mockUserId, claims[rancherUserID]) +}
pkg/auth/providers/saml/saml_provider.go+2 −1 modified@@ -127,7 +127,8 @@ func PerformSamlLogin(name string, apiContext *types.APIContext, input interface provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_RequestID", login.RequestID) provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_ResponseType", login.ResponseType) - idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request) + // userID is not needed for login. It's only needed for testAndEnable + idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request, "") if err != nil { return err }
f36b896a9944Expand SAML test coverage (#48964)
6 files changed · +245 −14
pkg/auth/providers/saml/saml_actions.go+1 −2 modified@@ -64,11 +64,10 @@ func (s *Provider) testAndEnable(actionName string, action *types.Action, reques logrus.Debugf("SAML [testAndEnable]: Final redirect will be (%v)", finalRedirectURL) provider.clientState.SetPath(provider.serviceProvider.AcsURL.Path) - provider.clientState.SetState(request.Response, request.Request, "Rancher_UserID", provider.userMGR.GetUser(request)) provider.clientState.SetState(request.Response, request.Request, "Rancher_FinalRedirectURL", finalRedirectURL) provider.clientState.SetState(request.Response, request.Request, "Rancher_Action", testAndEnableAction) - idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request) + idpRedirectURL, err := provider.HandleSamlLogin(request.Response, request.Request, provider.userMGR.GetUser(request)) if err != nil { return err }
pkg/auth/providers/saml/saml_client.go+38 −9 modified@@ -18,6 +18,7 @@ import ( "time" "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" "github.com/gorilla/mux" responsewriter "github.com/rancher/apiserver/pkg/middleware" v32 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" @@ -307,7 +308,6 @@ func (s *Provider) FinalizeSamlLogout(w http.ResponseWriter, r *http.Request) { redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") - s.clientState.DeleteState(w, r, "Rancher_UserID") s.clientState.DeleteState(w, r, "Rancher_Action") s.clientState.DeleteState(w, r, "Rancher_FinalRedirectURL") @@ -358,12 +358,7 @@ func (s *Provider) FinalizeSamlLogout(w http.ResponseWriter, r *http.Request) { func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) { var groupPrincipals []v3.Principal var userPrincipal v3.Principal - - if relayState := r.Form.Get("RelayState"); relayState != "" { - // delete the cookie - s.clientState.DeleteState(w, r, relayState) - } - + var userID string redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") rancherAction := s.clientState.GetState(r, "Rancher_Action") @@ -373,9 +368,20 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a } redirectURL += loginPath } else if rancherAction == testAndEnableAction { + var err error + userID, err = s.getUserIdFromRelayStateCookie(r) + if err != nil { + log.Errorf("SAML: Error getting state from cookie: %v", err) + http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) + return + } // the first query param is config=saml_provider_name set by UI redirectURL += "&" } + if relayState := r.Form.Get("RelayState"); relayState != "" { + // delete the cookie + s.clientState.DeleteState(w, r, relayState) + } samlData := make(map[string][]string) @@ -419,7 +425,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a return } - userID := s.clientState.GetState(r, "Rancher_UserID") if userID != "" && rancherAction == testAndEnableAction { user, err := s.userMGR.SetPrincipalOnCurrentUserByUserID(userID, userPrincipal) if err != nil && user == nil { @@ -449,7 +454,6 @@ func (s *Provider) HandleSamlAssertion(w http.ResponseWriter, r *http.Request, a http.Redirect(w, r, redirectURL+"errorCode=500", http.StatusFound) } // delete the cookies - s.clientState.DeleteState(w, r, "Rancher_UserID") s.clientState.DeleteState(w, r, "Rancher_Action") redirectURL := s.clientState.GetState(r, "Rancher_FinalRedirectURL") s.clientState.DeleteState(w, r, "Rancher_FinalRedirectURL") @@ -588,3 +592,28 @@ func (s *Provider) setRancherToken(w http.ResponseWriter, tokenMGR *tokens.Manag return nil } + +func (s *Provider) getUserIdFromRelayStateCookie(r *http.Request) (string, error) { + userID := "" + // The state is stored in a cookie, which has the relay state as the key and a JWT token containing the userID as the value + if relayState := r.Form.Get("RelayState"); relayState != "" { + relayStateCookie := s.clientState.GetState(r, relayState) + jwtParser := jwt.Parser{ + ValidMethods: []string{jwt.SigningMethodHS256.Name}, + } + token, err := jwtParser.Parse(relayStateCookie, func(t *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(s.serviceProvider.Key) + return secretBlock, nil + }) + if err != nil { + return "", fmt.Errorf("error parsing relay state token: %w", err) + } + if !token.Valid { + return "", fmt.Errorf("invalid token") + } + claims := token.Claims.(jwt.MapClaims) + userID, _ = claims[rancherUserID].(string) + } + + return userID, nil +}
pkg/auth/providers/saml/saml_client_test.go+142 −0 added@@ -0,0 +1,142 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetUserIdFromRelayState(t *testing.T) { + host := "http://www.rancher.com/" + relayStateValue := "mockValue" + mockUserID := "u-neuwrd" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + tests := map[string]struct { + createRequest func() *http.Request + wantUserID string + wantErr string + }{ + "valid userId": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantUserID: mockUserID, + }, + "userId not present": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + secretBlock := x509.MarshalPKCS1PrivateKey(privateKey) + state := jwt.New(jwt.SigningMethodHS256) + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + }, + "relay state not present": { + createRequest: func() *http.Request { + return httptest.NewRequest(http.MethodPost, host, nil) + }, + }, + "invalid token": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=wrongToken"}, + } + + return req + }, + wantErr: "error parsing relay state token", + }, + "state signed with a different key": { + createRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, host, nil) + req.Form = map[string][]string{ + "RelayState": {relayStateValue}, + } + assert.NoError(t, req.ParseForm()) + + anotherKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + secretBlock := x509.MarshalPKCS1PrivateKey(anotherKey) + state := jwt.New(jwt.SigningMethodHS256) + claims := state.Claims.(jwt.MapClaims) + claims[rancherUserID] = mockUserID + + signedState, err := state.SignedString(secretBlock) + assert.NoError(t, err) + + req.Header = map[string][]string{ + "Cookie": {"saml_Rancher_FinalRedirectURL=redirectURL;saml_" + relayStateValue + "=" + signedState}, + } + + return req + }, + wantErr: "signature is invalid", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + } + p := Provider{ + clientState: cookieStore, + serviceProvider: &saml.ServiceProvider{ + Key: privateKey, + }, + } + + userID, err := p.getUserIdFromRelayStateCookie(test.createRequest()) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + } else { + assert.NoError(t, err) + } + assert.Equal(t, test.wantUserID, userID) + }) + } +}
pkg/auth/providers/saml/saml_handlers.go+6 −1 modified@@ -13,6 +13,8 @@ import ( log "github.com/sirupsen/logrus" ) +const rancherUserID = "rancherUserID" + // ServeHTTP is the handler for /saml/metadata and /saml/acs endpoints func (s *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { serviceProvider := s.serviceProvider @@ -100,7 +102,7 @@ func (s *Provider) getPossibleRequestIDs(r *http.Request) []string { } // HandleSamlLogin is the endpoint for /saml/login endpoint -func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (string, error) { +func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request, userID string) (string, error) { serviceProvider := s.serviceProvider if r.URL.Path == serviceProvider.AcsURL.Path { return "", fmt.Errorf("don't wrap Middleware with RequireAccount") @@ -124,6 +126,9 @@ func (s *Provider) HandleSamlLogin(w http.ResponseWriter, r *http.Request) (stri claims := state.Claims.(jwt.MapClaims) claims["id"] = req.ID claims["uri"] = r.URL.String() + if userID != "" { + claims[rancherUserID] = userID + } signedState, err := state.SignedString(secretBlock) if err != nil {
pkg/auth/providers/saml/saml_handlers_test.go+56 −0 added@@ -0,0 +1,56 @@ +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "github.com/crewjam/saml" + "github.com/golang-jwt/jwt" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestHandleSamlLoginAddsRancherUserIdToCookie(t *testing.T) { + host := "http://www.rancher.com/" + mockUserId := "u-sidfes" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + serviceProvider := &saml.ServiceProvider{ + Key: privateKey, + IDPMetadata: &saml.EntityDescriptor{}, + } + cookieStore := &ClientCookies{ + Name: "token", + Domain: host, + Path: "/", + ServiceProvider: serviceProvider, + } + p := Provider{ + serviceProvider: serviceProvider, + clientState: cookieStore, + } + w := httptest.NewRecorder() + + urlParams, err := p.HandleSamlLogin(w, httptest.NewRequest(http.MethodPost, host, nil), mockUserId) + + assert.NoError(t, err) + values, err := url.ParseQuery(urlParams) + assert.NoError(t, err) + relayState := values.Get("RelayState") + // extract token from cookies. The key of the cookie is the relay state string, and the value is the token + // e.g saml_DY3XlRnQsITgTsegiY-QuB38OsawU64uNZE4Q5iYCWIZOfz9YO6IYvUS=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImlkLTNkY2ViYTQ2MWE0Njg2YzRkYWEyNTZkYjI1YjZmMWFjNWE0YWY2Y2MiLCJyYW5jaGVyVXNlcklkIjoidS1zaWRmZXMiLCJ1cmkiOiJodHRwOi8vd3d3LnJhbmNoZXIuY29tLyJ9.oeUMC0d6FyNt2WlrgxsUjf4QQPIcjjpugULiQ87ep4M; Path=/; Max-Age=90; HttpOnly + cookie, err := http.ParseSetCookie(w.Header().Get("Set-Cookie")) + assert.NoError(t, err) + assert.Equal(t, "saml_"+relayState, cookie.Name) + token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) { + secretBlock := x509.MarshalPKCS1PrivateKey(serviceProvider.Key) + return secretBlock, nil + }) + assert.NoError(t, err) + claims, _ := token.Claims.(jwt.MapClaims) + assert.Equal(t, mockUserId, claims[rancherUserID]) +}
pkg/auth/providers/saml/saml_provider.go+2 −2 modified@@ -167,7 +167,6 @@ func (s *Provider) LogoutAll(apiContext *types.APIContext, token *v3.Token) erro w := apiContext.Response provider.clientState.SetPath(provider.serviceProvider.SloURL.Path) provider.clientState.SetState(w, r, "Rancher_FinalRedirectURL", finalRedirectURL) - provider.clientState.SetState(w, r, "Rancher_UserID", userName) provider.clientState.SetState(w, r, "Rancher_Action", "logout-all") idpRedirectURL, err := provider.HandleSamlLogout(userAtProvider, w, r) @@ -213,7 +212,8 @@ func PerformSamlLogin(name string, apiContext *types.APIContext, input interface provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_RequestID", login.RequestID) provider.clientState.SetState(apiContext.Response, apiContext.Request, "Rancher_ResponseType", login.ResponseType) - idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request) + // userID is not needed for login. It's only needed for testAndEnable + idpRedirectURL, err := provider.HandleSamlLogin(apiContext.Response, apiContext.Request, "") if err != nil { return err }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
14- github.com/advisories/GHSA-mq23-vvg7-xfm4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-23389ghsaADVISORY
- bugzilla.suse.com/show_bug.cginvdWEB
- github.com/rancher/rancher/commit/4b885322eaf9995a1054bb46e019841653dc0d10ghsaWEB
- github.com/rancher/rancher/commit/cda77b743788feb8df8aedf9fd409ed0916a8723ghsaWEB
- github.com/rancher/rancher/commit/f36b896a99441985a1658e1b8c504d77e52fee4fghsaWEB
- github.com/rancher/rancher/pull/48964ghsaWEB
- github.com/rancher/rancher/pull/49030ghsaWEB
- github.com/rancher/rancher/pull/49031ghsaWEB
- github.com/rancher/rancher/releases/tag/v2.10.3ghsaWEB
- github.com/rancher/rancher/releases/tag/v2.8.13ghsaWEB
- github.com/rancher/rancher/releases/tag/v2.9.7ghsaWEB
- github.com/rancher/rancher/security/advisories/GHSA-mq23-vvg7-xfm4nvdWEB
- pkg.go.dev/vuln/GO-2025-3490ghsaWEB
News mentions
0No linked articles in our index yet.