mirror of
https://github.com/versity/versitygw.git
synced 2026-07-23 00:12:24 +00:00
9ac52cd5c7
LDAP-backed IAM lookups built a search filter using the request access key before signature verification, allowing filter metacharacters to alter query semantics. This made account lookup behavior observable and could be abused as a pre-authentication oracle in affected LDAP deployments. This change escapes access key values with ldap.EscapeFilter when constructing LDAP search filters and adds a regression test that verifies metacharacters are encoded rather than interpreted as filter operators. Also add DN escaping along with this fix too. Reported by GitHub user KrisKennawayDD.
343 lines
9.4 KiB
Go
343 lines
9.4 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
|
"github.com/go-ldap/ldap/v3"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
)
|
|
|
|
type LdapIAMService struct {
|
|
conn *ldap.Conn
|
|
queryBase string
|
|
objClasses []string
|
|
accessAtr string
|
|
secretAtr string
|
|
roleAtr string
|
|
groupIdAtr string
|
|
userIdAtr string
|
|
projectIdAtr string
|
|
rootAcc Account
|
|
url string
|
|
bindDN string
|
|
pass string
|
|
tlsSkipVerify bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
var _ IAMService = &LdapIAMService{}
|
|
|
|
func NewLDAPService(rootAcc Account, ldapURL, bindDN, pass, queryBase, accAtr, secAtr, roleAtr, userIdAtr, groupIdAtr, projectIdAtr, objClasses string, tlsSkipVerify bool) (IAMService, error) {
|
|
if ldapURL == "" || bindDN == "" || pass == "" || queryBase == "" || accAtr == "" ||
|
|
secAtr == "" || roleAtr == "" || userIdAtr == "" || groupIdAtr == "" || projectIdAtr == "" || objClasses == "" {
|
|
return nil, fmt.Errorf("required parameters list not fully provided")
|
|
}
|
|
|
|
conn, err := dialLDAP(ldapURL, tlsSkipVerify)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
|
|
}
|
|
|
|
err = conn.Bind(bindDN, pass)
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("failed to bind to LDAP server %w", err)
|
|
}
|
|
return &LdapIAMService{
|
|
conn: conn,
|
|
queryBase: queryBase,
|
|
objClasses: strings.Split(objClasses, ","),
|
|
accessAtr: accAtr,
|
|
secretAtr: secAtr,
|
|
roleAtr: roleAtr,
|
|
userIdAtr: userIdAtr,
|
|
groupIdAtr: groupIdAtr,
|
|
projectIdAtr: projectIdAtr,
|
|
rootAcc: rootAcc,
|
|
url: ldapURL,
|
|
bindDN: bindDN,
|
|
pass: pass,
|
|
tlsSkipVerify: tlsSkipVerify,
|
|
}, nil
|
|
}
|
|
|
|
// dialLDAP establishes an LDAP connection with optional TLS configuration
|
|
func dialLDAP(ldapURL string, tlsSkipVerify bool) (*ldap.Conn, error) {
|
|
u, err := url.Parse(ldapURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid LDAP URL: %w", err)
|
|
}
|
|
|
|
// For ldaps:// URLs, use DialURL with custom TLS config if needed
|
|
if u.Scheme == "ldaps" && tlsSkipVerify {
|
|
tlsConfig := &tls.Config{
|
|
InsecureSkipVerify: tlsSkipVerify,
|
|
}
|
|
return ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(tlsConfig))
|
|
}
|
|
|
|
// For ldap:// or when TLS verification is enabled, use standard DialURL
|
|
return ldap.DialURL(ldapURL)
|
|
}
|
|
|
|
func (ld *LdapIAMService) reconnect() error {
|
|
ld.conn.Close()
|
|
|
|
conn, err := dialLDAP(ld.url, ld.tlsSkipVerify)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reconnect to LDAP server: %w", err)
|
|
}
|
|
|
|
err = conn.Bind(ld.bindDN, ld.pass)
|
|
if err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("failed to bind to LDAP server on reconnect: %w", err)
|
|
}
|
|
ld.conn = conn
|
|
return nil
|
|
}
|
|
|
|
func (ld *LdapIAMService) execute(f func(*ldap.Conn) error) error {
|
|
ld.mu.Lock()
|
|
defer ld.mu.Unlock()
|
|
|
|
err := f(ld.conn)
|
|
if err != nil {
|
|
if e, ok := err.(*ldap.Error); ok && e.ResultCode == ldap.ErrorNetwork {
|
|
if reconnErr := ld.reconnect(); reconnErr != nil {
|
|
return reconnErr
|
|
}
|
|
return f(ld.conn)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (ld *LdapIAMService) CreateAccount(account Account) error {
|
|
if ld.rootAcc.Access == account.Access {
|
|
return ErrUserExists
|
|
}
|
|
userEntry := ldap.NewAddRequest(ld.buildUserDN(account.Access), nil)
|
|
userEntry.Attribute("objectClass", ld.objClasses)
|
|
userEntry.Attribute(ld.accessAtr, []string{account.Access})
|
|
userEntry.Attribute(ld.secretAtr, []string{account.Secret})
|
|
userEntry.Attribute(ld.roleAtr, []string{string(account.Role)})
|
|
userEntry.Attribute(ld.groupIdAtr, []string{fmt.Sprint(account.GroupID)})
|
|
userEntry.Attribute(ld.userIdAtr, []string{fmt.Sprint(account.UserID)})
|
|
userEntry.Attribute(ld.projectIdAtr, []string{fmt.Sprint(account.ProjectID)})
|
|
|
|
err := ld.execute(func(c *ldap.Conn) error {
|
|
return c.Add(userEntry)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("error adding an entry: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ld *LdapIAMService) buildUserDN(access string) string {
|
|
return fmt.Sprintf("%v=%v,%v", ld.accessAtr, ldap.EscapeDN(access), ld.queryBase)
|
|
}
|
|
|
|
func (ld *LdapIAMService) buildSearchFilter(access string) string {
|
|
var searchFilter strings.Builder
|
|
for _, el := range ld.objClasses {
|
|
searchFilter.WriteString(fmt.Sprintf("(objectClass=%v)", el))
|
|
}
|
|
if access != "" {
|
|
searchFilter.WriteString(fmt.Sprintf("(%v=%v)", ld.accessAtr, ldap.EscapeFilter(access)))
|
|
}
|
|
return fmt.Sprintf("(&%v)", searchFilter.String())
|
|
}
|
|
|
|
func (ld *LdapIAMService) GetUserAccount(access string) (Account, error) {
|
|
if access == ld.rootAcc.Access {
|
|
return ld.rootAcc, nil
|
|
}
|
|
var result *ldap.SearchResult
|
|
searchRequest := ldap.NewSearchRequest(
|
|
ld.queryBase,
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0,
|
|
0,
|
|
false,
|
|
ld.buildSearchFilter(access),
|
|
[]string{ld.accessAtr, ld.secretAtr, ld.roleAtr, ld.userIdAtr, ld.groupIdAtr, ld.projectIdAtr},
|
|
nil,
|
|
)
|
|
|
|
if debuglogger.IsIAMDebugEnabled() {
|
|
debuglogger.IAMLogf("LDAP Search Request")
|
|
debuglogger.IAMLogf(spew.Sdump(searchRequest))
|
|
}
|
|
|
|
err := ld.execute(func(c *ldap.Conn) error {
|
|
var err error
|
|
result, err = c.Search(searchRequest)
|
|
return err
|
|
})
|
|
|
|
if debuglogger.IsIAMDebugEnabled() {
|
|
debuglogger.IAMLogf("LDAP Search Result")
|
|
debuglogger.IAMLogf(spew.Sdump(result))
|
|
}
|
|
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
if len(result.Entries) == 0 {
|
|
return Account{}, ErrNoSuchUser
|
|
}
|
|
|
|
entry := result.Entries[0]
|
|
groupId, err := strconv.Atoi(entry.GetAttributeValue(ld.groupIdAtr))
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("invalid entry value for group-id %q: %w",
|
|
entry.GetAttributeValue(ld.groupIdAtr), err)
|
|
}
|
|
userId, err := strconv.Atoi(entry.GetAttributeValue(ld.userIdAtr))
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("invalid entry value for user-id %q: %w",
|
|
entry.GetAttributeValue(ld.userIdAtr), err)
|
|
}
|
|
projectID, err := strconv.Atoi(entry.GetAttributeValue(ld.projectIdAtr))
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("invalid entry value for project-id %q: %w",
|
|
entry.GetAttributeValue(ld.projectIdAtr), err)
|
|
}
|
|
|
|
return Account{
|
|
Access: entry.GetAttributeValue(ld.accessAtr),
|
|
Secret: entry.GetAttributeValue(ld.secretAtr),
|
|
Role: Role(entry.GetAttributeValue(ld.roleAtr)),
|
|
GroupID: groupId,
|
|
UserID: userId,
|
|
ProjectID: projectID,
|
|
}, nil
|
|
}
|
|
|
|
func (ld *LdapIAMService) UpdateUserAccount(access string, props MutableProps) error {
|
|
req := ldap.NewModifyRequest(ld.buildUserDN(access), nil)
|
|
if props.Secret != nil {
|
|
req.Replace(ld.secretAtr, []string{*props.Secret})
|
|
}
|
|
if props.GroupID != nil {
|
|
req.Replace(ld.groupIdAtr, []string{fmt.Sprint(*props.GroupID)})
|
|
}
|
|
if props.UserID != nil {
|
|
req.Replace(ld.userIdAtr, []string{fmt.Sprint(*props.UserID)})
|
|
}
|
|
if props.ProjectID != nil {
|
|
req.Replace(ld.projectIdAtr, []string{fmt.Sprint(*props.ProjectID)})
|
|
}
|
|
if props.Role != "" {
|
|
req.Replace(ld.roleAtr, []string{string(props.Role)})
|
|
}
|
|
|
|
err := ld.execute(func(c *ldap.Conn) error {
|
|
return c.Modify(req)
|
|
})
|
|
//TODO: Handle non existing user case
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ld *LdapIAMService) DeleteUserAccount(access string) error {
|
|
delReq := ldap.NewDelRequest(ld.buildUserDN(access), nil)
|
|
|
|
err := ld.execute(func(c *ldap.Conn) error {
|
|
return c.Del(delReq)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ld *LdapIAMService) ListUserAccounts() ([]Account, error) {
|
|
var resp *ldap.SearchResult
|
|
searchRequest := ldap.NewSearchRequest(
|
|
ld.queryBase,
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0,
|
|
0,
|
|
false,
|
|
ld.buildSearchFilter(""),
|
|
[]string{ld.accessAtr, ld.secretAtr, ld.roleAtr, ld.groupIdAtr, ld.projectIdAtr, ld.userIdAtr},
|
|
nil,
|
|
)
|
|
|
|
err := ld.execute(func(c *ldap.Conn) error {
|
|
var err error
|
|
resp, err = c.Search(searchRequest)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := []Account{}
|
|
for _, el := range resp.Entries {
|
|
groupId, err := strconv.Atoi(el.GetAttributeValue(ld.groupIdAtr))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid entry value for group-id %q: %w",
|
|
el.GetAttributeValue(ld.groupIdAtr), err)
|
|
}
|
|
userId, err := strconv.Atoi(el.GetAttributeValue(ld.userIdAtr))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid entry value for user-id %q: %w",
|
|
el.GetAttributeValue(ld.userIdAtr), err)
|
|
}
|
|
projectID, err := strconv.Atoi(el.GetAttributeValue(ld.projectIdAtr))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid entry value for project-id %q: %w",
|
|
el.GetAttributeValue(ld.groupIdAtr), err)
|
|
}
|
|
|
|
result = append(result, Account{
|
|
Access: el.GetAttributeValue(ld.accessAtr),
|
|
Secret: el.GetAttributeValue(ld.secretAtr),
|
|
Role: Role(el.GetAttributeValue(ld.roleAtr)),
|
|
GroupID: groupId,
|
|
ProjectID: projectID,
|
|
UserID: userId,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Shutdown graceful termination of service
|
|
func (ld *LdapIAMService) Shutdown() error {
|
|
ld.mu.Lock()
|
|
defer ld.mu.Unlock()
|
|
return ld.conn.Close()
|
|
}
|