dataprovider: refuse the login if a group setting uses %role% without a role

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino
2026-09-06 17:30:33 +02:00
parent aadffac363
commit 80383acf38
10 changed files with 237 additions and 17 deletions
+3
View File
@@ -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
+48 -13
View File
@@ -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 {
+33
View File
@@ -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)
}
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+67
View File
@@ -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)
+43
View File
@@ -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)
}
+4 -1
View File
@@ -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
+34
View File
@@ -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)
}
+2 -1
View File
@@ -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