ldap-upstream/internal/ldap/util.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

97 lines
2.7 KiB
Go

package ldap
import (
"errors"
"strings"
"github.com/vjeantet/goldap/message"
)
// entityIDFromDN parses out an ID from a given DN. This only works
// for ID's that don't contain an escaped comma, but these aren't
// valid in NetAuth anyway, so this is taken as a known defect.
func (s *server) entityIDFromDN(dn message.LDAPDN) (string, error) {
parts := strings.Split(string(dn), ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 2 {
return "", errors.New("malformed DN")
}
if !strings.HasPrefix(parts[0], "uid=") {
return "", errors.New("entity DN must start with uid=")
}
if parts[1] != "ou=entities" {
return "", errors.New("entity DN must be underneath ou=entities")
}
rest := parts[2:]
if len(rest) != len(s.nc) {
return "", errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
for i, p := range rest {
if p != s.nc[i] {
return "", errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
}
return strings.TrimPrefix(parts[0], "uid="), nil
}
// errUnsupportedFilter marks a filter this bridge cannot evaluate.
// Returning it lets callers answer with an explicit error rather than
// an accidentally empty result.
var errUnsupportedFilter = errors.New("unsupported search filter")
type entryKind int
const (
kindEntity entryKind = iota
kindGroup
)
// entryRef names the single entry a DN addresses directly, as opposed
// to a container DN that heads a subtree search.
type entryRef struct {
kind entryKind
name string
}
// entryFromDN parses a DN that names one entity or group outright,
// e.g. uid=jsmith,ou=entities,dc=netauth,dc=example,dc=com. Clients
// that already know a DN (HashiCorp Vault does this on every login)
// use such DNs as the base of a base-object search.
func (s *server) entryFromDN(dn message.LDAPDN) (*entryRef, error) {
parts := strings.Split(string(dn), ",")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
if len(parts) < 3 {
return nil, errors.New("malformed DN")
}
rest := parts[2:]
if len(rest) != len(s.nc) {
return nil, errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
for i, p := range rest {
if p != s.nc[i] {
return nil, errors.New("queries must be rooted at " + strings.Join(s.nc, ","))
}
}
switch {
case strings.HasPrefix(parts[0], "uid=") && parts[1] == "ou=entities":
return &entryRef{kind: kindEntity, name: strings.TrimPrefix(parts[0], "uid=")}, nil
case strings.HasPrefix(parts[0], "cn=") && parts[1] == "ou=groups":
return &entryRef{kind: kindGroup, name: strings.TrimPrefix(parts[0], "cn=")}, nil
}
return nil, errors.New("DN does not name an entity or a group")
}