diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index bc91453b..8f551f8a 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -178,6 +178,9 @@ var ( ValidProtocols = []string{protocolSSH, protocolFTP, protocolWebDAV, protocolHTTP} // MFAProtocols defines the supported protocols for multi-factor authentication MFAProtocols = []string{protocolHTTP, protocolSSH, protocolFTP} + // ErrPlaceholderUnset is returned when a group setting uses a placeholder + // the account has no value for. The login is refused + ErrPlaceholderUnset = errors.New("a group setting uses a placeholder the account has no value for") // ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required ErrNoInitRequired = errors.New("the data provider is up to date") // ErrInvalidCredentials defines the error to return if the supplied credentials are invalid diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index a4c48a5b..252a23ab 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -1621,6 +1621,11 @@ func (u *User) applyGroupSettings(groupsMapping map[string]Group) { return } replacer := u.getGroupPlacehodersReplacer() + defer func() { + if replacer.err != nil { + providerLog(logger.LevelError, "unable to apply group settings to user %q: %v", u.Username, replacer.err) + } + }() for _, g := range u.Groups { if g.Type == sdk.GroupTypePrimary { if group, ok := groupsMapping[g.Name]; ok { @@ -1689,22 +1694,49 @@ func (u *User) loadAndApplyGroupSettings() error { g := groups[idx] u.mergeAdditiveProperties(&g, sdk.GroupTypeSecondary, replacer) } + if replacer.err != nil { + providerLog(logger.LevelError, "unable to apply group settings to user %q: %v", u.Username, replacer.err) + return replacer.err + } u.removeDuplicatesAfterGroupMerge() return nil } -func (u *User) getGroupPlacehodersReplacer() *strings.Replacer { - return strings.NewReplacer("%username%", u.Username, "%role%", u.Role) +type groupRenderer struct { + *strings.Replacer + user *User + err error } -func (u *User) replacePlaceholder(value string, replacer *strings.Replacer) string { +func (r *groupRenderer) render(value string) string { if value == "" { return value } - return replacer.Replace(value) + if r.user.Role == "" && strings.Contains(value, "%role%") { + r.fail(value) + return value + } + return r.Replacer.Replace(value) } -func (u *User) replaceFsConfigPlaceholders(fsConfig vfs.Filesystem, replacer *strings.Replacer) vfs.Filesystem { +func (r *groupRenderer) fail(value string) { + if r.err == nil { + r.err = fmt.Errorf("%w: %q", ErrPlaceholderUnset, value) + } +} + +func (u *User) getGroupPlacehodersReplacer() *groupRenderer { + return &groupRenderer{ + Replacer: strings.NewReplacer("%username%", u.Username, "%role%", u.Role), + user: u, + } +} + +func (u *User) replacePlaceholder(value string, replacer *groupRenderer) string { + return replacer.render(value) +} + +func (u *User) replaceFsConfigPlaceholders(fsConfig vfs.Filesystem, replacer *groupRenderer) vfs.Filesystem { switch fsConfig.Provider { case sdk.S3FilesystemProvider: fsConfig.S3Config.KeyPrefix = u.replacePlaceholder(fsConfig.S3Config.KeyPrefix, replacer) @@ -1732,7 +1764,7 @@ func (u *User) mergeCryptFsConfig(group *Group) { } } -func (u *User) mergeWithPrimaryGroup(group *Group, replacer *strings.Replacer) { +func (u *User) mergeWithPrimaryGroup(group *Group, replacer *groupRenderer) { if group.UserSettings.HomeDir != "" { u.HomeDir = filepath.Clean(u.replacePlaceholder(group.UserSettings.HomeDir, replacer)) } @@ -1774,7 +1806,7 @@ func (u *User) mergeWithPrimaryGroup(group *Group, replacer *strings.Replacer) { u.mergeAdditiveProperties(group, sdk.GroupTypePrimary, replacer) } -func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *strings.Replacer) { +func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *groupRenderer) { if u.Filters.MaxUploadFileSize == 0 { u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize } @@ -1822,7 +1854,7 @@ func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer * } } -func (u *User) mergeAdditiveProperties(group *Group, groupType int, replacer *strings.Replacer) { +func (u *User) mergeAdditiveProperties(group *Group, groupType int, replacer *groupRenderer) { u.mergeVirtualFolders(group, groupType, replacer) u.mergePermissions(group, groupType, replacer) u.mergeFilePatterns(group, groupType, replacer) @@ -1836,7 +1868,7 @@ func (u *User) mergeAdditiveProperties(group *Group, groupType int, replacer *st u.Filters.AccessTime = append(u.Filters.AccessTime, group.UserSettings.Filters.AccessTime...) } -func (u *User) mergeVirtualFolders(group *Group, groupType int, replacer *strings.Replacer) { +func (u *User) mergeVirtualFolders(group *Group, groupType int, replacer *groupRenderer) { if len(group.VirtualFolders) > 0 { folderPaths := make(map[string]bool) for _, folder := range u.VirtualFolders { @@ -1871,18 +1903,21 @@ func countPathSegments(p string) int { return strings.Count(p, "/") } -func (u *User) renderSubPathValue(value string, replacer *strings.Replacer) (string, bool) { +func (u *User) renderSubPathValue(value string, replacer *groupRenderer) (string, bool) { if value == "" || !strings.Contains(value, "%") { return value, true } - rendered := util.CleanPath(replacer.Replace(value)) + if u.Role == "" && strings.Contains(value, "%role%") { + return "", false + } + rendered := util.CleanPath(replacer.render(value)) if countPathSegments(rendered) < countPathSegments(value) { return "", false } return rendered, true } -func (u *User) mergePermissions(group *Group, groupType int, replacer *strings.Replacer) { +func (u *User) mergePermissions(group *Group, groupType int, replacer *groupRenderer) { if u.Permissions == nil { u.Permissions = make(map[string][]string) } @@ -1901,7 +1936,7 @@ func (u *User) mergePermissions(group *Group, groupType int, replacer *strings.R } } -func (u *User) mergeFilePatterns(group *Group, groupType int, replacer *strings.Replacer) { +func (u *User) mergeFilePatterns(group *Group, groupType int, replacer *groupRenderer) { if len(group.UserSettings.Filters.FilePatterns) > 0 { patternPaths := make(map[string]bool) for _, pattern := range u.Filters.FilePatterns { diff --git a/internal/ftpd/internal_test.go b/internal/ftpd/internal_test.go index 9da8c7b9..ca312ad2 100644 --- a/internal/ftpd/internal_test.go +++ b/internal/ftpd/internal_test.go @@ -1218,3 +1218,36 @@ func TestPassiveHost(t *testing.T) { assert.NoError(t, err, ip) assert.Equal(t, "127.0.0.1", ip) } + +func TestLoginMetricsPlaceholderUnset(t *testing.T) { + oldConfig := common.Config + cfg := common.Config + cfg.DefenderConfig.Enabled = true + cfg.DefenderConfig.Driver = common.DefenderDriverMemory + cfg.DefenderConfig.Threshold = 100 + cfg.DefenderConfig.ScoreInvalid = 2 + cfg.DefenderConfig.ScoreValid = 2 + err := common.Initialize(cfg, 0) + require.NoError(t, err) + + t.Cleanup(func() { + err := common.Initialize(oldConfig, 0) + assert.NoError(t, err) + }) + + ip := "172.16.34.7" + user := dataprovider.User{} + user.Username = "ftp_metrics_user" + c := &Connection{BaseConnection: common.NewBaseConnection("", common.ProtocolFTP, "", "", user)} + + updateLoginMetrics(&user, ip, dataprovider.LoginMethodPassword, dataprovider.ErrPlaceholderUnset, c) + hosts, err := common.GetDefenderHosts() + assert.NoError(t, err) + assert.Empty(t, hosts) + + updateLoginMetrics(&user, ip, dataprovider.LoginMethodPassword, dataprovider.ErrInvalidCredentials, c) + hosts, err = common.GetDefenderHosts() + assert.NoError(t, err) + assert.Len(t, hosts, 1) + common.DeleteDefenderHost(ip) +} diff --git a/internal/ftpd/server.go b/internal/ftpd/server.go index 2be78c37..f435fa1e 100644 --- a/internal/ftpd/server.go +++ b/internal/ftpd/server.go @@ -464,7 +464,7 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err c.clientContext.HasTLSForControl(), info) plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, common.ProtocolFTP, user.Username, ip, "", nil) common.DelayLogin(nil) - } else if err != common.ErrInternalFailure { + } else if err != common.ErrInternalFailure && !errors.Is(err, dataprovider.ErrPlaceholderUnset) { logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolFTP, err.Error()) event := common.HostEventLoginFailed logEv := notifier.LogEventTypeLoginFailed diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index ef2c5f9b..1a717ac8 100644 --- a/internal/httpd/api_utils.go +++ b/internal/httpd/api_utils.go @@ -750,7 +750,8 @@ func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err err logger.LoginLog(user.Username, ip, loginMethod, protocol, "", r.UserAgent(), r.TLS != nil, "") plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, protocol, user.Username, ip, "", nil) common.DelayLogin(nil) - } else if err != common.ErrInternalFailure && err != common.ErrNoCredentials { + } else if err != common.ErrInternalFailure && err != common.ErrNoCredentials && + !errors.Is(err, dataprovider.ErrPlaceholderUnset) { logger.ConnectionFailedLog(user.Username, ip, loginMethod, protocol, err.Error()) err = handleDefenderEventLoginFailed(ip, err) logEv := notifier.LogEventTypeLoginFailed diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 0ce290ff..6081dd7a 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1598,6 +1598,73 @@ func TestGroupValidation(t *testing.T) { assert.Contains(t, string(resp), "invalid web client options") } +func TestGroupPlaceholderWithoutValue(t *testing.T) { + base := filepath.Join(os.TempDir(), util.GenerateUniqueID()) + g := getTestGroup() + g.UserSettings.HomeDir = filepath.Join(base, ".%role%.") + group, _, err := httpdtest.AddGroup(g, http.StatusCreated) + assert.NoError(t, err) + u := getTestUser() + u.Groups = []sdk.GroupMapping{{Name: group.Name, Type: sdk.GroupTypePrimary}} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + _, err = dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP) + assert.ErrorIs(t, err, dataprovider.ErrPlaceholderUnset) + _, err = dataprovider.GetUserWithGroupSettings(defaultUsername, "") + assert.ErrorIs(t, err, dataprovider.ErrPlaceholderUnset) + + // with a role the token renders a name + r := getTestRole() + role, _, err := httpdtest.AddRole(r, http.StatusCreated) + assert.NoError(t, err) + user.Role = role.Name + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + merged, err := dataprovider.CheckUserAndPass(defaultUsername, defaultPassword, "", common.ProtocolHTTP) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(base, "."+role.Name+"."), merged.HomeDir) + + // a folder sub-path is the exception: the mapping the account cannot render + // is left unmounted, the login is unaffected. The mount is not created at + // the folder root, which would widen the scope the sub-path declares + folder, _, err := httpdtest.AddFolder(vfs.BaseVirtualFolder{ + Name: "tpl_folder", + MappedPath: filepath.Join(base, "folder"), + }, http.StatusCreated) + assert.NoError(t, err) + group.UserSettings.HomeDir = "" + group.VirtualFolders = []vfs.VirtualFolder{{ + BaseVirtualFolder: vfs.BaseVirtualFolder{Name: folder.Name}, + VirtualPath: "/vdir", + Subpath: "/tenants%role%", + }} + group, _, err = httpdtest.UpdateGroup(group, http.StatusOK) + assert.NoError(t, err) + merged, err = dataprovider.GetUserWithGroupSettings(defaultUsername, "") + assert.NoError(t, err) + if assert.Len(t, merged.VirtualFolders, 1) { + assert.Equal(t, "/tenants"+role.Name, merged.VirtualFolders[0].Subpath) + } + user.Role = "" + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + merged, err = dataprovider.GetUserWithGroupSettings(defaultUsername, "") + assert.NoError(t, err) + assert.Empty(t, merged.VirtualFolders, "the mapping is dropped, /tenants is not mounted") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveGroup(group, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveRole(role, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(base) + assert.NoError(t, err) +} + func TestGroupSettingsOverride(t *testing.T) { mappedPath1 := filepath.Join(os.TempDir(), util.GenerateUniqueID()) folderName1 := filepath.Base(mappedPath1) diff --git a/internal/sftpd/internal_test.go b/internal/sftpd/internal_test.go index e752c7d1..5b296289 100644 --- a/internal/sftpd/internal_test.go +++ b/internal/sftpd/internal_test.go @@ -2309,3 +2309,46 @@ func TestOsFsResolvePathSymlinkLoops(t *testing.T) { t.Logf("loop case %q terminated, err=%v", p, err) } } + +func TestCheckAuthErrorAccountConfig(t *testing.T) { + oldConfig := common.Config + cfg := common.Config + cfg.DefenderConfig.Enabled = true + cfg.DefenderConfig.Driver = common.DefenderDriverMemory + cfg.DefenderConfig.Threshold = 100 + cfg.DefenderConfig.ScoreInvalid = 2 + cfg.DefenderConfig.ScoreValid = 2 + err := common.Initialize(cfg, 0) + require.NoError(t, err) + + t.Cleanup(func() { + err := common.Initialize(oldConfig, 0) + assert.NoError(t, err) + }) + + newAuthError := func(err error, username string) error { + return &authenticationError{ + err: err, + loginMethod: dataprovider.SSHLoginMethodPublicKey, + username: username, + } + } + ip := "172.16.44.9" + checkAuthError(ip, &ssh.ServerAuthError{ + Errors: []error{newAuthError(dataprovider.ErrPlaceholderUnset, "misconfigured")}, + }) + hosts, err := common.GetDefenderHosts() + assert.NoError(t, err) + assert.Empty(t, hosts) + + checkAuthError(ip, &ssh.ServerAuthError{ + Errors: []error{ + newAuthError(dataprovider.ErrPlaceholderUnset, "misconfigured"), + newAuthError(dataprovider.ErrInvalidCredentials, "guessed"), + }, + }) + hosts, err = common.GetDefenderHosts() + assert.NoError(t, err) + assert.Len(t, hosts, 1, "the guess following the misconfiguration is scored") + common.DeleteDefenderHost(ip) +} diff --git a/internal/sftpd/server.go b/internal/sftpd/server.go index 8589e11a..5ce5c9b2 100644 --- a/internal/sftpd/server.go +++ b/internal/sftpd/server.go @@ -813,6 +813,9 @@ func checkAuthError(ip string, err error) { // check public key auth errors here for _, err := range authErrors.Errors { if sftpAuthErr, ok := errors.AsType[*authenticationError](err); ok { + if errors.Is(err, dataprovider.ErrPlaceholderUnset) { + continue + } if sftpAuthErr.getLoginMethod() == dataprovider.SSHLoginMethodPublicKey { event := common.HostEventLoginFailed logEv := notifier.LogEventTypeLoginFailed @@ -1330,7 +1333,7 @@ func updateLoginMetrics(user *dataprovider.User, ip, method string, err error) { common.DelayLogin(nil) } else { logger.ConnectionFailedLog(user.Username, ip, method, common.ProtocolSSH, err.Error()) - if method != dataprovider.SSHLoginMethodPublicKey { + if method != dataprovider.SSHLoginMethodPublicKey && !errors.Is(err, dataprovider.ErrPlaceholderUnset) { // some clients try all available public keys for a user, we // record failed login key auth only once for session if the // authentication fails in checkAuthError diff --git a/internal/webdavd/internal_test.go b/internal/webdavd/internal_test.go index 196308af..2f0b997b 100644 --- a/internal/webdavd/internal_test.go +++ b/internal/webdavd/internal_test.go @@ -1872,3 +1872,37 @@ func TestBindingIsValid(t *testing.T) { }) } } + +func TestLoginMetricsPlaceholderUnset(t *testing.T) { + oldConfig := common.Config + cfg := common.Config + cfg.DefenderConfig.Enabled = true + cfg.DefenderConfig.Driver = common.DefenderDriverMemory + cfg.DefenderConfig.Threshold = 100 + cfg.DefenderConfig.ScoreInvalid = 2 + cfg.DefenderConfig.ScoreValid = 2 + err := common.Initialize(cfg, 0) + require.NoError(t, err) + + t.Cleanup(func() { + err := common.Initialize(oldConfig, 0) + assert.NoError(t, err) + }) + + ip := "172.16.34.8" + user := dataprovider.User{} + user.Username = "dav_metrics_user" + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + updateLoginMetrics(&user, ip, dataprovider.LoginMethodPassword, dataprovider.ErrPlaceholderUnset, req) + hosts, err := common.GetDefenderHosts() + assert.NoError(t, err) + assert.Empty(t, hosts) + + updateLoginMetrics(&user, ip, dataprovider.LoginMethodPassword, dataprovider.ErrInvalidCredentials, req) + hosts, err = common.GetDefenderHosts() + assert.NoError(t, err) + assert.Len(t, hosts, 1) + common.DeleteDefenderHost(ip) +} diff --git a/internal/webdavd/server.go b/internal/webdavd/server.go index 9613822b..abac4d7b 100644 --- a/internal/webdavd/server.go +++ b/internal/webdavd/server.go @@ -454,7 +454,8 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err logger.LoginLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, "", r.UserAgent(), r.TLS != nil, "") plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, common.ProtocolWebDAV, user.Username, ip, "", nil) common.DelayLogin(nil) - } else if err != common.ErrInternalFailure && err != common.ErrNoCredentials { + } else if err != common.ErrInternalFailure && err != common.ErrNoCredentials && + !errors.Is(err, dataprovider.ErrPlaceholderUnset) { logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error()) event := common.HostEventLoginFailed logEv := notifier.LogEventTypeLoginFailed