ldap-upstream/internal/ldap/search.go
Michael Aldridge 31b9b295aa internal/ldap: Answer base-object searches for one entry
HashiCorp Vault reads the attributes of the user it just bound as by
issuing a base-object "(objectClass=*)" search whose base DN is the
user's own DN, and has no option to turn that step off. Those searches
named a leaf entry rather than a container, so nothing routed them and
login failed with unwillingToPerform.

A single entry is now looked up by ID or name and returned under the DN
the client asked for, advertising objectClass as the other entries do.
An unknown name reports noSuchObject so clients can distinguish it from
an empty result, an entry that does not match the filter succeeds with
no entries as RFC 4511 requires, and filter shapes we cannot evaluate
are refused instead of guessed at. The catch-all route is limited to
base-object scope so searches with an unknown subtree base are still
refused, which keeps a renamed server from answering its old base DN.

💘 Generated with Crush

Assisted-by: Crush:qwen38_flash_next_mtp
2026-11-02 19:59:53 -06:00

364 lines
12 KiB
Go

package ldap
import (
"context"
"strconv"
"strings"
"github.com/vjeantet/goldap/message"
ldap "github.com/vjeantet/ldapserver"
"github.com/netauth/ldap/internal/buildinfo"
pb "github.com/netauth/protocol"
)
func (s *server) handleSearchDSE(w ldap.ResponseWriter, m *ldap.Message) {
nc := strings.Join(s.nc, ", ")
e := ldap.NewSearchResultEntry("")
e.AddAttribute("vendorName", "NetAuth")
e.AddAttribute("vendorVersion", message.AttributeValue(buildinfo.Version))
e.AddAttribute("objectClass", "top", "extensibleObject")
e.AddAttribute("supportedLDAPVersion", "3")
e.AddAttribute("namingContexts", message.AttributeValue(nc))
w.Write(e)
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess)
w.Write(res)
}
// objectClasses are the structural classes advertised on every entry.
// NetAuth has no schema, so entries are presented as the least
// surprising generic classes.
var objectClasses = []message.AttributeValue{"top", "extensibleObject"}
// handleSearchEntry answers a base-object search addressed at a single
// entity or group DN. Some clients (HashiCorp Vault among them) read
// the attributes of one known entry with a base-scope
// "(objectClass=*)" search instead of a subtree search, and NetAuth
// has no per-entry lookup, so the entry is fetched by its ID or name
// and then checked against the filter.
func (s *server) handleSearchEntry(w ldap.ResponseWriter, m *ldap.Message) {
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
defer cancel()
if !s.bound(m) {
s.l.Warn("Search from an unauthenticated connection")
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights)
res.SetDiagnosticMessage("a bind is required before searching")
w.Write(res)
return
}
r := m.GetSearchRequest()
dn := string(r.BaseObject())
ref, err := s.entryFromDN(r.BaseObject())
if err != nil {
s.l.Debug("Base scope search for something we do not have", "dn", dn)
writeSearchDone(w, ldap.LDAPResultNoSuchObject,
"only entity and group DNs can be searched directly, e.g. uid=<name>,ou=entities,"+strings.Join(s.nc, ","))
return
}
entry, found, err := s.fetchEntry(ctx, ref, dn, r.Attributes())
if err != nil {
s.l.Warn("NetAuth entry lookup failed", "dn", dn, "error", err)
writeSearchDone(w, ldap.LDAPResultOperationsError, err.Error())
return
}
if !found {
writeSearchDone(w, ldap.LDAPResultNoSuchObject, "")
return
}
match, err := entryFilterMatches(r.Filter(), ref.name)
if err != nil {
s.l.Warn("Unsupported filter on a base scope search", "dn", dn, "error", err)
writeSearchDone(w, ldap.LDAPResultUnwillingToPerform, "Filter type not supported")
return
}
// RFC 4511: a base-object search whose entry does not match the
// filter succeeds and returns zero entries.
if !match {
writeSearchDone(w, ldap.LDAPResultSuccess, "")
return
}
w.Write(entry)
writeSearchDone(w, ldap.LDAPResultSuccess, "")
}
// writeSearchDone terminates a search operation.
func writeSearchDone(w ldap.ResponseWriter, code int, diagnostic string) {
res := ldap.NewSearchResultDoneResponse(code)
if diagnostic != "" {
res.SetDiagnosticMessage(diagnostic)
}
w.Write(res)
}
// fetchEntry looks up a single entity or group in NetAuth and renders
// it under the exact DN the client asked for. The boolean reports
// whether NetAuth knows the name at all.
func (s *server) fetchEntry(ctx context.Context, ref *entryRef, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, bool, error) {
switch ref.kind {
case kindEntity:
expr, err := mapToBleveStringQuery("uid", ref.name)
if err != nil {
return message.SearchResultEntry{}, false, err
}
ents, err := s.c.EntitySearch(ctx, expr)
if err != nil {
return message.SearchResultEntry{}, false, err
}
for i := range ents {
if !strings.EqualFold(ents[i].GetID(), ref.name) {
continue
}
entry, err := s.entityAttributes(ctx, ents[i], dn, attrs)
return entry, err == nil, err
}
case kindGroup:
expr, err := mapToBleveStringQuery("cn", ref.name)
if err != nil {
return message.SearchResultEntry{}, false, err
}
grps, err := s.c.GroupSearch(ctx, expr)
if err != nil {
return message.SearchResultEntry{}, false, err
}
for i := range grps {
if !strings.EqualFold(grps[i].GetName(), ref.name) {
continue
}
entry, err := s.groupAttributes(ctx, grps[i], dn, attrs)
return entry, err == nil, err
}
}
return message.SearchResultEntry{}, false, nil
}
// entryFilterMatches reports whether a search filter selects a single
// entry whose identifying attribute has the given name. Only the
// filter forms this bridge models are evaluated; anything else is an
// error so that a client is never handed a wrong answer instead.
func entryFilterMatches(f message.Filter, name string) (bool, error) {
switch f := f.(type) {
case message.FilterPresent:
switch strings.ToLower(string(f)) {
case "objectclass", "uid", "cn":
return true, nil
}
return false, errUnsupportedFilter
case message.FilterEqualityMatch:
value := string(f.AssertionValue())
switch strings.ToLower(string(f.AttributeDesc())) {
case "uid", "cn":
return strings.EqualFold(value, name), nil
case "objectclass":
return strings.EqualFold(value, "top") || strings.EqualFold(value, "extensibleObject"), nil
}
return false, errUnsupportedFilter
case message.FilterAnd:
for _, sub := range f {
ok, err := entryFilterMatches(sub, name)
if err != nil || !ok {
return false, err
}
}
return true, nil
case message.FilterOr:
for _, sub := range f {
ok, err := entryFilterMatches(sub, name)
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, nil
case message.FilterNot:
ok, err := entryFilterMatches(f.Filter, name)
return !ok, err
}
return false, errUnsupportedFilter
}
func (s *server) handleSearchEntities(w ldap.ResponseWriter, m *ldap.Message) {
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
defer cancel()
s.l.Debug("Search Entities")
if !s.bound(m) {
s.l.Warn("Search from an unauthenticated connection")
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights)
res.SetDiagnosticMessage("a bind is required before searching")
w.Write(res)
return
}
r := m.GetSearchRequest()
expr, err := s.buildBleveQuery(r.Filter())
if err != nil {
// If err is non-nil at this point it must mean that
// the above match didn't find a supported filter.
s.l.Warn("Unsupported Search Filter, this is a bug, please file a report", "filter", r.Filter())
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultUnwillingToPerform)
res.SetDiagnosticMessage("Filter type not supported")
w.Write(res)
return
}
s.l.Debug("Searching entities", "query", expr)
ents, err := s.c.EntitySearch(ctx, expr)
if err != nil {
s.l.Warn("NetAuth entity search failed", "query", expr, "error", err)
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError)
res.SetDiagnosticMessage(err.Error())
w.Write(res)
return
}
for i := range ents {
e, err := s.entitySearchResult(ctx, ents[i], r.BaseObject(), r.Attributes())
if err != nil {
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError)
res.SetDiagnosticMessage(err.Error())
w.Write(res)
return
}
w.Write(e)
}
s.l.Debug("Entities", "res", ents)
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess)
w.Write(res)
}
// entitySearchResult maps an entity from a subtree search onto a
// SearchResultEntry, deriving the entry DN from the search base.
func (s *server) entitySearchResult(ctx context.Context, e *pb.Entity, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
return s.entityAttributes(ctx, e, "uid="+e.GetID()+","+string(dn), attrs)
}
// entityAttributes maps an entity onto a SearchResultEntry at the
// given full DN. The extra lookup needed to populate memberOf is only
// performed when the client asked for that attribute, since it costs
// one NetAuth round trip per entity.
func (s *server) entityAttributes(ctx context.Context, e *pb.Entity, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
res := ldap.NewSearchResultEntry(dn)
res.AddAttribute("objectClass", objectClasses...)
res.AddAttribute("uid", message.AttributeValue(e.GetID()))
res.AddAttribute("uidNumber", message.AttributeValue(strconv.Itoa(int(e.GetNumber()))))
if !attributeRequested(attrs, "memberOf") {
return res, nil
}
grps, err := s.c.EntityGroups(ctx, e.GetID())
if err != nil {
return res, err
}
memberOf := []message.AttributeValue{}
for i := range grps {
g := "cn=" + grps[i].GetName() + ",ou=groups," + strings.Join(s.nc, ",")
memberOf = append(memberOf, message.AttributeValue(g))
}
res.AddAttribute("memberOf", memberOf...)
return res, nil
}
func (s *server) handleSearchGroups(w ldap.ResponseWriter, m *ldap.Message) {
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
defer cancel()
s.l.Debug("Search Groups")
if !s.bound(m) {
s.l.Warn("Search from an unauthenticated connection")
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultInsufficientAccessRights)
res.SetDiagnosticMessage("a bind is required before searching")
w.Write(res)
return
}
r := m.GetSearchRequest()
expr, err := s.buildBleveQuery(r.Filter())
if err != nil {
// If err is non-nil at this point it must mean that
// the above match didn't find a supported filter.
s.l.Warn("Unsupported Search Filter, this is a bug, please file a report", "filter", r.Filter())
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultUnwillingToPerform)
res.SetDiagnosticMessage("Filter type not supported")
w.Write(res)
return
}
s.l.Debug("Searching groups", "expr", expr)
groups, err := s.c.GroupSearch(ctx, expr)
if err != nil {
s.l.Warn("NetAuth group search failed", "query", expr, "error", err)
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError)
res.SetDiagnosticMessage(err.Error())
w.Write(res)
return
}
for i := range groups {
s.l.Debug("Found group", "group", groups[i].GetName())
e, err := s.groupSearchResult(ctx, groups[i], r.BaseObject(), r.Attributes())
if err != nil {
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultOperationsError)
res.SetDiagnosticMessage(err.Error())
w.Write(res)
return
}
w.Write(e)
}
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess)
w.Write(res)
}
// groupSearchResult maps a group from a subtree search onto a
// SearchResultEntry, deriving the entry DN from the search base.
func (s *server) groupSearchResult(ctx context.Context, g *pb.Group, dn message.LDAPDN, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
return s.groupAttributes(ctx, g, "cn="+g.GetName()+","+string(dn), attrs)
}
// groupAttributes maps a group onto a SearchResultEntry at the given
// full DN. The extra lookup needed to populate member is only
// performed when the client asked for that attribute, since it costs
// one NetAuth round trip per group.
func (s *server) groupAttributes(ctx context.Context, g *pb.Group, dn string, attrs message.AttributeSelection) (message.SearchResultEntry, error) {
res := ldap.NewSearchResultEntry(dn)
res.AddAttribute("objectClass", objectClasses...)
res.AddAttribute("cn", message.AttributeValue(g.GetName()))
res.AddAttribute("gidNumber", message.AttributeValue(strconv.Itoa(int(g.GetNumber()))))
if !attributeRequested(attrs, "member") {
return res, nil
}
members, err := s.c.GroupMembers(ctx, g.GetName())
if err != nil {
return res, err
}
memberList := []message.AttributeValue{}
for i := range members {
g := "uid=" + members[i].GetID() + ",ou=entities," + strings.Join(s.nc, ",")
memberList = append(memberList, message.AttributeValue(g))
}
res.AddAttribute("member", memberList...)
return res, nil
}