From f89cde43b602808fb1b97ca106eefc104f9108ee Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 22 Jul 2026 07:29:15 -0400 Subject: [PATCH] Harden user creation from invite Fixed an issue where an SSO-only invitation could be accepted with a password, creating a local password-authenticated account and bypassing SSO enforcement. The authentication mode is now derived solely from the invite. Derive the authentication mode from the invite record instead of client input during invite acceptance. --- changes/16783-sso-invite-password-acceptance | 1 + server/service/integration_core_test.go | 117 +++++++++++++++++++ server/service/users.go | 19 ++- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 changes/16783-sso-invite-password-acceptance diff --git a/changes/16783-sso-invite-password-acceptance b/changes/16783-sso-invite-password-acceptance new file mode 100644 index 0000000000..77fe25ac5d --- /dev/null +++ b/changes/16783-sso-invite-password-acceptance @@ -0,0 +1 @@ +- Fixed an issue where an SSO-only invitation could be accepted with a password, creating a local password-authenticated account and bypassing SSO enforcement. The authentication mode is now derived solely from the invite. diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index cd1490b569..ff3ab13604 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3023,6 +3023,123 @@ func (s *integrationTestSuite) TestCreateUserFromInviteErrors() { } } +func (s *integrationTestSuite) TestCreateUserFromSSOInvite() { + t := s.T() + ctx := context.Background() + + createInvite := func(email string, ssoEnabled bool) *fleet.Invite { + createInviteReq := createInviteRequest{InvitePayload: fleet.InvitePayload{ + Email: new(email), + Name: new("SSO Invitee"), + GlobalRole: null.StringFrom(fleet.RoleObserver), + SSOEnabled: new(ssoEnabled), + }} + createInviteResp := createInviteResponse{} + s.DoJSON("POST", "/api/latest/fleet/invites", createInviteReq, http.StatusOK, &createInviteResp) + t.Cleanup(func() { + // Ignore the error: an accepted invite is consumed (deleted) by the + // acceptance flow, so it may no longer exist at cleanup time. + _ = s.ds.DeleteInvite(ctx, createInviteResp.Invite.ID) + }) + // the token is not returned via the response's json, must get it from the db + invite, err := s.ds.Invite(ctx, createInviteResp.Invite.ID) + require.NoError(t, err) + return invite + } + + // An SSO-only invite must not be acceptable through the password flow. + t.Run("sso invite rejects password payload", func(t *testing.T) { + email := "sso-password-attack@b.c" + invite := createInvite(email, true) + + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{ + Name: new("Attacker"), + Email: new(email), + Password: &test.GoodPassword, + InviteToken: new(invite.Token), + }, http.StatusUnprocessableEntity, &resp) + + // no user should have been created + _, err := s.ds.UserByEmail(ctx, email) + require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err) + }) + + // An empty password field on an SSO invite must also be rejected: the + // presence of the field at all is not allowed. + t.Run("sso invite rejects empty password field", func(t *testing.T) { + email := "sso-empty-password@b.c" + invite := createInvite(email, true) + + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{ + Name: new("Attacker"), + Email: new(email), + Password: new(""), + SSOInvite: new(true), + InviteToken: new(invite.Token), + }, http.StatusUnprocessableEntity, &resp) + + _, err := s.ds.UserByEmail(ctx, email) + require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err) + }) + + // The legitimate SSO acceptance flow must create an SSO-enabled user. + t.Run("sso invite accepted via sso flow", func(t *testing.T) { + email := "sso-legit@b.c" + invite := createInvite(email, true) + + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{ + Name: new("SSO User"), + Email: new(email), + SSOInvite: new(true), + InviteToken: new(invite.Token), + }, http.StatusOK, &resp) + require.NotNil(t, resp.User) + require.True(t, resp.User.SSOEnabled) + t.Cleanup(func() { require.NoError(t, s.ds.DeleteUser(ctx, resp.User.ID)) }) + }) + + // A non-SSO invite accepted with SSO flags but no password must be rejected + // (and must not panic on a nil password). + t.Run("password invite rejects sso payload without password", func(t *testing.T) { + email := "password-as-sso@b.c" + invite := createInvite(email, false) + + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{ + Name: new("No Password"), + Email: new(email), + SSOInvite: new(true), + InviteToken: new(invite.Token), + }, http.StatusUnprocessableEntity, &resp) + + _, err := s.ds.UserByEmail(ctx, email) + require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err) + }) + + // A non-SSO invite accepted with SSOInvite falsely set must still enforce + // password complexity: setting the SSO flag must not let a weak password + // slip past validation. + t.Run("password invite rejects sso payload with weak password", func(t *testing.T) { + email := "password-as-sso-weak@b.c" + invite := createInvite(email, false) + + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{ + Name: new("Weak Password"), + Email: new(email), + Password: new("weak"), // too short, no number or symbol + SSOInvite: new(true), + InviteToken: new(invite.Token), + }, http.StatusUnprocessableEntity, &resp) + + _, err := s.ds.UserByEmail(ctx, email) + require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err) + }) +} + func (s *integrationTestSuite) TestGetHostSummary() { t := s.T() ctx := context.Background() diff --git a/server/service/users.go b/server/service/users.go index d54b3f66e8..12a5a8f533 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -431,9 +431,26 @@ func (svc *Service) CreateUserFromInvite(ctx context.Context, p fleet.UserPayloa // set the payload role property based on an existing invite. p.GlobalRole = invite.GlobalRole.Ptr() p.Teams = &invite.Teams - p.MFAEnabled = ptr.Bool(invite.MFAEnabled) + p.MFAEnabled = new(invite.MFAEnabled) // Invite ID is only used as a uniq index to prevent a double invite acceptance race condition p.InviteID = &invite.ID + p.SSOEnabled = new(invite.SSOEnabled) + p.SSOInvite = new(invite.SSOEnabled) + if invite.SSOEnabled { + // SSO invites must not create local password credentials. Reject the + // payload if it carries a password field at all, even an empty one. + if p.Password != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", "not allowed for SSO invitations")) + } + } else { + // Non-SSO invites require a valid password. + if p.Password == nil || *p.Password == "" { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", "Password missing required argument")) + } + if err := fleet.ValidatePasswordRequirements(*p.Password); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", err.Error())) + } + } user, err := svc.NewUser(ctx, p) if err != nil {