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) } } }