lib/api: Better handle %s templates in LDAP strings (fixes #9072) (#9155)

Also add some escaping for good measure.
This commit is contained in:
Jakob Borg 2023-10-07 04:29:53 +02:00 committed by GitHub
parent 9f8e6966d8
commit 3d0da5ac60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 88 additions and 18 deletions

View File

@ -260,7 +260,8 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
defer connection.Close() defer connection.Close()
err = connection.Bind(ldapTemplateBindDN(cfg.BindDN, username), password) bindDN := formatOptionalPercentS(cfg.BindDN, escapeForLDAPDN(username))
err = connection.Bind(bindDN, password)
if err != nil { if err != nil {
l.Warnln("LDAP Bind:", err) l.Warnln("LDAP Bind:", err)
return false return false
@ -280,7 +281,7 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
// the user. If this matches precisely one user then we are good to go. // the user. If this matches precisely one user then we are good to go.
// The search filter uses the same %s interpolation as the bind DN. // The search filter uses the same %s interpolation as the bind DN.
searchString := fmt.Sprintf(cfg.SearchFilter, username) searchString := formatOptionalPercentS(cfg.SearchFilter, escapeForLDAPFilter(username))
const sizeLimit = 2 // we search for up to two users -- we only want to match one, so getting any number >1 is a failure. const sizeLimit = 2 // we search for up to two users -- we only want to match one, so getting any number >1 is a failure.
const timeLimit = 60 // Search for up to a minute... const timeLimit = 60 // Search for up to a minute...
searchReq := ldap.NewSearchRequest(cfg.SearchBaseDN, ldap.ScopeWholeSubtree, ldap.DerefFindingBaseObj, sizeLimit, timeLimit, false, searchString, nil, nil) searchReq := ldap.NewSearchRequest(cfg.SearchBaseDN, ldap.ScopeWholeSubtree, ldap.DerefFindingBaseObj, sizeLimit, timeLimit, false, searchString, nil, nil)
@ -298,13 +299,37 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
return true return true
} }
func ldapTemplateBindDN(bindDN string, username string) string { // escapeForLDAPFilter escapes a value that will be used in a filter clause
// Check if formatting directives are included in the ldapTemplateBindDN - if so add username. func escapeForLDAPFilter(value string) string {
// (%%s is a literal %s - unlikely for LDAP, but easy to handle here). // https://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx#Special_Characters
if strings.Count(bindDN, "%s") != strings.Count(bindDN, "%%s") { // Backslash must always be first in the list so we don't double escape them.
bindDN = fmt.Sprintf(bindDN, username) return escapeRunes(value, []rune{'\\', '*', '(', ')', 0})
}
// escapeForLDAPDN escapes a value that will be used in a bind DN
func escapeForLDAPDN(value string) string {
// https://social.technet.microsoft.com/wiki/contents/articles/5312.active-directory-characters-to-escape.aspx
// Backslash must always be first in the list so we don't double escape them.
return escapeRunes(value, []rune{'\\', ',', '#', '+', '<', '>', ';', '"', '=', ' ', 0})
}
func escapeRunes(value string, runes []rune) string {
for _, e := range runes {
value = strings.ReplaceAll(value, string(e), fmt.Sprintf("\\%X", e))
} }
return bindDN return value
}
func formatOptionalPercentS(template string, username string) string {
var replacements []any
nReps := strings.Count(template, "%s") - strings.Count(template, "%%s")
if nReps < 0 {
nReps = 0
}
for i := 0; i < nReps; i++ {
replacements = append(replacements, username)
}
return fmt.Sprintf(template, replacements...)
} }
// Convert an ISO-8859-1 encoded byte string to UTF-8. Works by the // Convert an ISO-8859-1 encoded byte string to UTF-8. Works by the

View File

@ -46,22 +46,67 @@ func TestStaticAuthPasswordFail(t *testing.T) {
} }
} }
func TestAuthLDAPSendsCorrectBindDNWithTemplate(t *testing.T) { func TestFormatOptionalPercentS(t *testing.T) {
t.Parallel() t.Parallel()
templatedDn := ldapTemplateBindDN("cn=%s,dc=some,dc=example,dc=com", "username") cases := []struct {
expectedDn := "cn=username,dc=some,dc=example,dc=com" template string
if expectedDn != templatedDn { username string
t.Fatalf("ldapTemplateBindDN should be %s != %s", expectedDn, templatedDn) expected string
}{
{"cn=%s,dc=some,dc=example,dc=com", "username", "cn=username,dc=some,dc=example,dc=com"},
{"cn=fixedusername,dc=some,dc=example,dc=com", "username", "cn=fixedusername,dc=some,dc=example,dc=com"},
{"cn=%%s,dc=%s,dc=example,dc=com", "username", "cn=%s,dc=username,dc=example,dc=com"},
{"cn=%%s,dc=%%s,dc=example,dc=com", "username", "cn=%s,dc=%s,dc=example,dc=com"},
{"cn=%s,dc=%s,dc=example,dc=com", "username", "cn=username,dc=username,dc=example,dc=com"},
}
for _, c := range cases {
templatedDn := formatOptionalPercentS(c.template, c.username)
if c.expected != templatedDn {
t.Fatalf("result should be %s != %s", c.expected, templatedDn)
}
} }
} }
func TestAuthLDAPSendsCorrectBindDNWithNoTemplate(t *testing.T) { func TestEscapeForLDAPFilter(t *testing.T) {
t.Parallel() t.Parallel()
templatedDn := ldapTemplateBindDN("cn=fixedusername,dc=some,dc=example,dc=com", "username") cases := []struct {
expectedDn := "cn=fixedusername,dc=some,dc=example,dc=com" in string
if expectedDn != templatedDn { out string
t.Fatalf("ldapTemplateBindDN should be %s != %s", expectedDn, templatedDn) }{
{"username", `username`},
{"user(name", `user\28name`},
{"user)name", `user\29name`},
{"user\\name", `user\5Cname`},
{"user*name", `user\2Aname`},
{"*,CN=asdf", `\2A,CN=asdf`},
}
for _, c := range cases {
res := escapeForLDAPFilter(c.in)
if c.out != res {
t.Fatalf("result should be %s != %s", c.out, res)
}
}
}
func TestEscapeForLDAPDN(t *testing.T) {
t.Parallel()
cases := []struct {
in string
out string
}{
{"username", `username`},
{"* ,CN=asdf", `*\20\2CCN\3Dasdf`},
}
for _, c := range cases {
res := escapeForLDAPDN(c.in)
if c.out != res {
t.Fatalf("result should be %s != %s", c.out, res)
}
} }
} }