From 787db6638ffcc45c72f43e331f3ac322b37844b2 Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 18 Mar 2026 00:32:47 +0100 Subject: [PATCH] fix(PROJ-23): Privilege Escalation in Tenant-LDAP + Login-Reihenfolge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/ldap_tenants.go | 27 +++++++++++ internal/auth/auth.go | 88 +++++++++++++++++------------------- 2 files changed, 69 insertions(+), 46 deletions(-) diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index 2534acd..fd813c2 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -447,6 +447,20 @@ func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) { } 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 { writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") return @@ -575,6 +589,19 @@ func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Reques } 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()) if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil { writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 66b8254..1573c65 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -68,50 +68,9 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err return m.issueToken(user) } - // 2. Global LDAP fallback when the store is wired and the config is enabled. - 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 { - // 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. + // 2. PROJ-23: Per-tenant LDAP — checked first so tenant config takes priority + // over global LDAP (spec: tenant_ldap > ldap_config > config.yml). + // Extract domain from username (user@domain.tld), resolve tenant, try auth. if m.tenantLdapStore != nil && m.tenantLookup != nil { if domain := extractDomain(username); domain != "" { ctx := context.Background() @@ -142,12 +101,10 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err } } } - email := attrs["mail"] if email == "" { email = username } - ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID) if upsertErr == nil { 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") }