fix(PROJ-23): Privilege Escalation in Tenant-LDAP + Login-Reihenfolge

- BUG-1 (P0): domain_admin kann keine Rollen > auditor in default_role/
  group_mappings setzen — serverseitige Allowlist-Prüfung in
  handleSaveTenantLDAP (user/auditor) und handleAdminSaveTenantLDAP
  (user/auditor/domain_admin)
- WARN-1: Login-Fallback-Reihenfolge korrigiert — tenant_ldap wird
  jetzt VOR globalem ldap_config geprüft (Spec: tenant > global > local)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 00:32:47 +01:00
parent 9e7add31cd
commit 787db6638f
2 changed files with 69 additions and 46 deletions
+27
View File
@@ -447,6 +447,20 @@ func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) {
} }
cfg.TenantID = *sess.TenantID cfg.TenantID = *sess.TenantID
// BUG-1 fix: domain_admin may only assign user/auditor roles — prevent privilege escalation
// via LDAP default_role or group_mappings even when bypassing the frontend.
allowedForTenantAdmin := map[string]bool{"user": true, "auditor": true}
if cfg.DefaultRole != "" && !allowedForTenantAdmin[cfg.DefaultRole] {
writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config")
return
}
for _, gm := range cfg.GroupMappings {
if !allowedForTenantAdmin[gm.Role] {
writeError(w, http.StatusForbidden, "group mapping role not allowed")
return
}
}
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil { if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
return return
@@ -575,6 +589,19 @@ func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Reques
} }
cfg.TenantID = id cfg.TenantID = id
// superadmin may assign up to domain_admin in group mappings — not superadmin itself.
allowedForSuperAdmin := map[string]bool{"user": true, "auditor": true, "domain_admin": true}
if cfg.DefaultRole != "" && !allowedForSuperAdmin[cfg.DefaultRole] {
writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config")
return
}
for _, gm := range cfg.GroupMappings {
if !allowedForSuperAdmin[gm.Role] {
writeError(w, http.StatusForbidden, "group mapping role not allowed")
return
}
}
sess := sessionFromCtx(r.Context()) sess := sessionFromCtx(r.Context())
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil { if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
+42 -46
View File
@@ -68,50 +68,9 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
return m.issueToken(user) return m.issueToken(user)
} }
// 2. Global LDAP fallback when the store is wired and the config is enabled. // 2. PROJ-23: Per-tenant LDAP — checked first so tenant config takes priority
if m.ldapStore != nil { // over global LDAP (spec: tenant_ldap > ldap_config > config.yml).
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background()) // Extract domain from username (user@domain.tld), resolve tenant, try auth.
if ldapErr == nil && cfg != nil && cfg.Enabled {
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
URL: cfg.URL,
BindDN: cfg.BindDN,
BindPassword: cfg.BindPassword,
BaseDN: cfg.BaseDN,
UserFilter: cfg.UserFilter,
TLS: cfg.TLS,
TLSSkipVerify: cfg.TLSSkipVerify,
}, username, password)
if authErr == nil {
// Determine role: check group_mappings first, fall back to default_role.
role := cfg.DefaultRole
if role == "" {
role = userstore.RoleUser
}
memberOf := attrs["memberOf"]
if memberOf != "" {
for _, gm := range cfg.GroupMappings {
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
role = gm.Role
break
}
}
}
email := attrs["mail"]
if email == "" {
email = username + "@ldap.local"
}
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
if upsertErr == nil {
return m.issueToken(ldapUser)
}
}
}
}
// 3. PROJ-23: Per-tenant LDAP fallback — extract domain from username,
// look up the tenant, and try LDAP auth with that tenant's config.
if m.tenantLdapStore != nil && m.tenantLookup != nil { if m.tenantLdapStore != nil && m.tenantLookup != nil {
if domain := extractDomain(username); domain != "" { if domain := extractDomain(username); domain != "" {
ctx := context.Background() ctx := context.Background()
@@ -142,12 +101,10 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
} }
} }
} }
email := attrs["mail"] email := attrs["mail"]
if email == "" { if email == "" {
email = username email = username
} }
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID) ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
if upsertErr == nil { if upsertErr == nil {
return m.issueToken(ldapUser) return m.issueToken(ldapUser)
@@ -158,6 +115,45 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
} }
} }
// 3. Global LDAP fallback — only reached if no matching tenant LDAP config found.
if m.ldapStore != nil {
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
if ldapErr == nil && cfg != nil && cfg.Enabled {
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
URL: cfg.URL,
BindDN: cfg.BindDN,
BindPassword: cfg.BindPassword,
BaseDN: cfg.BaseDN,
UserFilter: cfg.UserFilter,
TLS: cfg.TLS,
TLSSkipVerify: cfg.TLSSkipVerify,
}, username, password)
if authErr == nil {
role := cfg.DefaultRole
if role == "" {
role = userstore.RoleUser
}
memberOf := attrs["memberOf"]
if memberOf != "" {
for _, gm := range cfg.GroupMappings {
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
role = gm.Role
break
}
}
}
email := attrs["mail"]
if email == "" {
email = username + "@ldap.local"
}
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
if upsertErr == nil {
return m.issueToken(ldapUser)
}
}
}
}
return "", nil, fmt.Errorf("auth: login: invalid credentials") return "", nil, fmt.Errorf("auth: login: invalid credentials")
} }