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

321 lines
11 KiB
Go

package ldap
import (
"bytes"
"testing"
"github.com/vjeantet/goldap/message"
ldap "github.com/vjeantet/ldapserver"
pb "github.com/netauth/protocol"
)
// berBaseObjectSearch encodes a base-object search for a single entry
// with an arbitrary filter element. This is the shape HashiCorp Vault
// sends on every login to read the attributes of the bound user.
func berBaseObjectSearch(t *testing.T, baseDN string, filterElement []byte) []byte {
t.Helper()
search := berElement(0x63, // [APPLICATION 3] constructed
berOctetString(baseDN), // baseObject
berElement(0x0a, []byte{0}), // scope: baseObject
berElement(0x0a, []byte{0}), // derefAliases: never
berInteger(0), // sizeLimit
berInteger(0), // timeLimit
berElement(0x01, []byte{0}), // typesOnly: false
filterElement, // filter
berElement(0x30), // empty attribute selection (all)
)
return berElement(0x30, berInteger(2), search)
}
// objectClassPresentFilter encodes the (objectClass=*) filter.
func objectClassPresentFilter() []byte { return berRawPresent("objectClass") }
// entryContains reports whether the encoded form of an entry contains
// an OCTET STRING holding exactly the given text.
func entryContains(t *testing.T, po message.ProtocolOp, text string) bool {
t.Helper()
m := message.NewLDAPMessageWithProtocolOp(po)
m.SetMessageID(2)
b, err := m.Write()
if err != nil {
t.Fatalf("encoding entry: %s", err)
}
// The value is encoded as a bare OCTET STRING with its exact
// length, which avoids matching a substring of a longer value.
want := []byte{0x04, byte(len(text))}
want = append(want, text...)
return bytes.Contains(b.Bytes(), want)
}
// entryHasAttribute encodes a SearchResultEntry and reports whether it
// carries the named attribute. goldap exposes no accessors for entry
// attributes, so the encoded form is inspected instead.
func entryHasAttribute(t *testing.T, po message.ProtocolOp, attr string) bool {
t.Helper()
return entryContains(t, po, attr)
}
// entryContainsDN reports whether an entry is recorded under the given
// distinguished name.
func entryContainsDN(t *testing.T, po message.ProtocolOp, dn string) bool {
t.Helper()
return entryContains(t, po, dn)
}
func vaultTestServer(c naClient) *server {
s := boundTestServer(c)
s.nc = []string{"dc=netauth", "dc=voidlinux", "dc=org"}
return s
}
// vaultBind authenticates the fixture naming context so the search is
// not refused before it reaches the handler logic under test. The
// shared bind helper uses a different naming context and cannot be
// used here.
func vaultBind(t *testing.T, s *server) {
t.Helper()
s.handleBind(&recordingWriter{}, mustDecode(t,
berBindRequest("uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", "hunter2")))
if !s.bound(&ldap.Message{}) {
t.Fatal("the fixture bind did not mark the connection bound")
}
}
// A base-object search for a known entity returns exactly that entry
// terminated by a searchResDone. Vault reads the entity alias out of
// the returned attributes, so failing to answer this aborts login.
func TestBaseObjectSearchReturnsEntry(t *testing.T) {
c := &fakeClient{
entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)},
}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 2 {
t.Fatalf("got %d responses, want 1 entry + 1 done", len(w.responses))
}
entry, ok := w.responses[0].(message.SearchResultEntry)
if !ok {
t.Fatalf("first response is %T, want SearchResultEntry", w.responses[0])
}
if _, ok := w.responses[1].(message.SearchResultDone); !ok {
t.Errorf("second response is %T, want SearchResultDone", w.responses[1])
}
// Vault requires the DN it asked for, not one rebuilt from the
// search base, and needs the userattr value to be present.
if !entryContainsDN(t, entry, "uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org") {
t.Errorf("entry DN is not the requested DN")
}
for _, attr := range []string{"uid", "objectClass"} {
if !entryHasAttribute(t, entry, attr) {
t.Errorf("entry is missing the %q attribute", attr)
}
}
}
// The alias Vault derives comes from the attribute the entry carries,
// so an entity whose NetAuth ID differs in case from the requested DN
// must still resolve.
func TestBaseObjectSearchIsCaseInsensitive(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=Maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultSuccess {
t.Fatalf("result code is %d, want success", code)
}
if len(w.responses) != 2 {
t.Errorf("got %d responses, want the entry plus a done", len(w.responses))
}
}
// A base-object search for an unknown name returns noSuchObject rather
// than an empty success, so clients can tell the difference.
func TestBaseObjectSearchUnknownEntity(t *testing.T) {
s := vaultTestServer(&fakeClient{})
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=ghost,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want only a done", len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultNoSuchObject {
t.Errorf("result code is %d, want %d (noSuchObject)", code, ldap.LDAPResultNoSuchObject)
}
}
// RFC 4511: a base-object search whose entry does not match the filter
// succeeds with zero entries.
func TestBaseObjectSearchFilterMismatch(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org",
berRawEquality("uid", "someoneelse"))))
if len(w.responses) != 1 {
t.Fatalf("got %d responses, want only a done", len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultSuccess {
t.Errorf("result code is %d, want success with no entries", code)
}
}
func TestBaseObjectSearchRequiresBind(t *testing.T) {
s := vaultTestServer(&fakeClient{})
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultInsufficientAccessRights {
t.Errorf("result code is %d, want %d (insufficientAccessRights)",
code, ldap.LDAPResultInsufficientAccessRights)
}
}
// A base-object search on a DN that is neither an entity nor a group
// (the naming context itself, an ou) is refused without ever reaching
// NetAuth.
func TestBaseObjectSearchOnContainerDN(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(c.entitySearchResults) == 0 {
t.Error("the fake client should still hold its fixture data")
}
if code := resultCode(t, w.responses[len(w.responses)-1]); code != ldap.LDAPResultNoSuchObject {
t.Errorf("result code is %d, want %d (noSuchObject)", code, ldap.LDAPResultNoSuchObject)
}
if len(w.responses) != 1 {
t.Errorf("got %d responses, want only a done", len(w.responses))
}
}
// Group DNs are served the same way entity DNs are.
func TestBaseObjectSearchGroup(t *testing.T) {
c := &fakeClient{
groupSearchResults: []*pb.Group{testGroup("admins", 42)},
groupMembersResults: map[string][]*pb.Entity{"admins": {testEntity("maldridge", 1001)}},
}
s := vaultTestServer(c)
vaultBind(t, s)
w := &recordingWriter{}
s.handleSearchEntry(w, mustDecode(t, berBaseObjectSearch(t,
"cn=admins,ou=groups,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(w.responses) != 2 {
t.Fatalf("got %d responses, want 1 entry + 1 done", len(w.responses))
}
entry, ok := w.responses[0].(message.SearchResultEntry)
if !ok {
t.Fatalf("first response is %T, want SearchResultEntry", w.responses[0])
}
if !entryHasAttribute(t, entry, "cn") {
t.Error("group entry is missing the cn attribute")
}
if !entryHasAttribute(t, entry, "member") {
t.Error("group entry is missing the member attribute")
}
}
// The single-entry handler is reached through the real mux, and adding
// it must not widen what other searches are accepted: an empty base or
// a stale base DN at subtree scope is still refused.
func TestBaseObjectSearchRouting(t *testing.T) {
c := &fakeClient{entitySearchResults: []*pb.Entity{testEntity("maldridge", 1001)}}
s := New(WithLogger(testLogger()), WithNetAuth(c))
if err := s.SetDomain("voidlinux.org"); err != nil {
t.Fatal(err)
}
fc := &fakeConn{}
s.conn = func(*ldap.Message) connection { return fc }
bind := &recordingWriter{}
s.ServeLDAP(bind, mustDecode(t,
berBindRequest("uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", "hunter2")))
if code := resultCode(t, bind.responses[len(bind.responses)-1]); code != ldap.LDAPResultSuccess {
t.Fatalf("bind result code is %d, want success", code)
}
entry := &recordingWriter{}
s.ServeLDAP(entry, mustDecode(t, berBaseObjectSearch(t,
"uid=maldridge,ou=entities,dc=netauth,dc=voidlinux,dc=org", objectClassPresentFilter())))
if len(entry.responses) != 2 {
t.Fatalf("got %d responses through the mux, want 1 entry + 1 done", len(entry.responses))
}
if _, ok := entry.responses[0].(message.SearchResultEntry); !ok {
t.Errorf("first response is %T, want SearchResultEntry", entry.responses[0])
}
for _, base := range []string{"", "ou=entities,dc=netauth,dc=example,dc=com"} {
w := &recordingWriter{}
s.ServeLDAP(w, mustDecode(t, berSearchRequest(base)))
if len(w.responses) != 1 {
t.Fatalf("base %q: got %d responses, want only a done", base, len(w.responses))
}
if code := resultCode(t, w.responses[0]); code != ldap.LDAPResultUnwillingToPerform {
t.Errorf("base %q: result code is %d, want %d (unwillingToPerform)",
base, code, ldap.LDAPResultUnwillingToPerform)
}
}
}
func TestEntryFromDN(t *testing.T) {
s := vaultTestServer(&fakeClient{})
ok := map[string]entryKind{
"uid=jsmith,ou=entities,dc=netauth,dc=voidlinux,dc=org": kindEntity,
"cn=admins,ou=groups,dc=netauth,dc=voidlinux,dc=org": kindGroup,
"uid=jsmith,ou=ENTITIES,dc=netauth,dc=voidlinux,dc=org": kindEntity,
}
for dn, want := range ok {
ref, err := s.entryFromDN(message.LDAPDN(dn))
if err != nil {
t.Errorf("entryFromDN(%q): %s", dn, err)
continue
}
if ref.kind != want {
t.Errorf("entryFromDN(%q) kind is %v, want %v", dn, ref.kind, want)
}
}
bad := []string{
"",
"uid=jsmith",
"uid=jsmith,ou=entities",
"ou=entities,dc=netauth,dc=voidlinux,dc=org",
"cn=admins,ou=entities,dc=netauth,dc=voidlinux,dc=org",
"uid=jsmith,ou=entities,dc=netauth,dc=example,dc=com",
}
for _, dn := range bad {
if ref, err := s.entryFromDN(message.LDAPDN(dn)); err == nil {
t.Errorf("entryFromDN(%q) accepted a non-entry DN (%+v)", dn, ref)
}
}
}