Searching a directory with LDAP
A protocol for directories
When scheduling needs to know who Dana Okafor's manager is, it does not read a file or call an API built for the purpose. It opens a connection to directory.harborclinic.example and asks in a language that directory servers and their clients have shared for decades: the Lightweight Directory Access Protocol (LDAP). RFC 4511 defines the protocol's operations and messages, and companion specifications define how entries are named and how searches are written.
An LDAP conversation follows a simple pattern. The client connects, usually authenticates with a bind, and then sends operations: search for entries, compare a value, add or delete an entry, change its attributes, or rename it. The server answers each request with a result. Many applications use only two of these operations, bind and search.
LDAP messages are binary, so nobody reads them directly. Directory tools read and write entries in a text format called LDIF, the LDAP Data Interchange Format, and the examples below use it to show entries and search results.
Names in a tree
An LDAP directory arranges its entries in a tree. Harbor's starts from entries for its domain name and branches into organizational units, one for staff and one for groups, with a unit for each department under staff.
Each entry has a distinguished name (DN) that identifies it by its position in the tree. Dana's is uid=dana.okafor,ou=pediatrics,ou=staff,dc=harborclinic,dc=example. Read it from right to left to walk down the tree: the example domain component (dc), then harborclinic, then the staff organizational unit (ou), then pediatrics, and finally the entry whose user ID (uid) is dana.okafor.
Each comma-separated part is a relative distinguished name (RDN), an attribute and value that tells an entry apart from its siblings. Only one entry directly under ou=pediatrics can be uid=dana.okafor. RFC 4514 defines how a DN is written as text, including how to escape characters such as a comma that appear inside a value.
Because a DN is a path, it changes whenever an entry moves or is renamed. When Sam Reyes transfers to oncology, Sam's entry moves from ou=pediatrics to ou=oncology, and Sam's DN changes with it. When Dana changes surname to Mensah, the uid changes and so does the DN. An application that used DNs as keys would lose track of both people. Many LDAP directories also give each entry an operational attribute that never changes, such as entryUUID, defined in RFC 4530. That is the value to store, for the reasons described in What a directory holds.
An entry's object classes say what kind of thing it is and which attributes it must or may have. Harbor's people use inetOrgPerson, a class widely used for people in organizations. It allows attributes such as uid, cn (common name), sn (surname), givenName, mail, employeeNumber, departmentNumber, title, and manager. Here is Dana's entry in LDIF:
dn: uid=dana.okafor,ou=pediatrics,ou=staff,dc=harborclinic,dc=example
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: inetOrgPerson
uid: dana.okafor
cn: Dana Okafor
givenName: Dana
sn: Okafor
mail: [email protected]
employeeNumber: E10482
departmentNumber: Pediatrics
title: Registered Nurse
All names, hosts, and identifiers in these examples are fictional.
The object classes build on one another: inetOrgPerson extends organizationalPerson, which extends person. The person class requires cn and sn, and the other attributes here are optional. The manager attribute, not shown, holds the DN of the manager's entry rather than a name.
Groups use classes such as groupOfNames, whose member attribute holds the DN of each member:
dn: cn=Pediatrics nurses,ou=groups,dc=harborclinic,dc=example
objectClass: top
objectClass: groupOfNames
cn: Pediatrics nurses
member: uid=dana.okafor,ou=pediatrics,ou=staff,dc=harborclinic,dc=example
member: uid=sam.reyes,ou=pediatrics,ou=staff,dc=harborclinic,dc=example
Harbor's identity system maintains this member list from the group's rule, so to the directory it is an ordinary list. Membership is stored on the group, as DNs, so when Sam's entry moves to oncology, every group that lists Sam's old DN has to be updated. Many directory servers can do that automatically when an entry is renamed. Where they do not, the group points at an entry that no longer exists.
Searching
A search request has four main parts. The base DN is the entry where the search starts. The scope says how far below the base to look. The filter is the condition an entry must match. Finally, the request lists the attributes to return.
There are three scopes. A base object search looks only at the base entry itself, which is how an application reads one entry whose DN it already knows. A single level search looks at the immediate children of the base, and not at the base itself. A whole subtree search looks at the base and everything below it, at any depth.
| Scope | Entries the search considers |
|---|---|
| Base object | ou=staff itself |
| Single level | ou=pediatrics, ou=oncology, and the other units directly under staff. Not ou=staff, and not the people inside each unit. |
| Whole subtree | ou=staff, every unit, and every person in every unit |
Filters use the syntax defined in RFC 4515. Each condition sits in its own parentheses, and conditions are combined by putting the operator first: & for and, | for or, and ! for not.
(uid=dana.okafor) the entry with this user name
(departmentNumber=Pediatrics) everyone in one department
(cn=Dan*) common names that start with Dan
(mail=*) entries with any email address
(&(objectClass=inetOrgPerson)(departmentNumber=Pediatrics))
people in pediatrics
(|(departmentNumber=Pediatrics)(departmentNumber=Oncology))
people in either department
(&(objectClass=inetOrgPerson)(!(manager=*)))
people with no manager recorded
The last filter answers a question from Sources of truth: which people have nobody to approve their requests or review their access? Here is a complete search for the people in pediatrics, followed by the beginning of its result:
Search request
base: ou=staff,dc=harborclinic,dc=example
scope: whole subtree
filter: (&(objectClass=inetOrgPerson)(departmentNumber=Pediatrics))
attributes: uid cn mail entryUUID
dn: uid=dana.okafor,ou=pediatrics,ou=staff,dc=harborclinic,dc=example
uid: dana.okafor
cn: Dana Okafor
mail: [email protected]
entryUUID: 5b1e9c7a-2f4d-4e8b-9a61-0c3d7f2e8b14
dn: uid=sam.reyes,ou=pediatrics,ou=staff,dc=harborclinic,dc=example
uid: sam.reyes
cn: Sam Reyes
mail: [email protected]
entryUUID: 0c7d2a91-6e3b-4f58-8d14-b9a2e6f30c57
(further entries, then a final result: success)
The server sends each matching entry as a separate message, then one final message that reports the result of the whole search. Only the requested attributes come back. If the request lists none, the server returns all of each entry's ordinary attributes, which is usually far more than an application needs. entryUUID is an operational attribute, and servers return operational attributes only when a search asks for them, so an application that wants to store it has to request it.
Checking group membership is a search too. To find out whether Dana is in "Pediatrics nurses", an application can use the group's DN as the base, a base object scope, and the filter (member=uid=dana.okafor,ou=pediatrics,ou=staff,dc=harborclinic,dc=example). If the group comes back, Dana is a direct member. A search like this sees only direct membership. If Dana belonged through a nested group, it would come back empty, which is one reason applications disagree about nested groups.
Binding
Before searching, a client usually binds. The bind authenticates the client to the directory, which then decides what that client may read or change. A bind is either a simple bind or a SASL bind, and in practice it takes one of three common forms.
An anonymous bind is a simple bind with an empty name and an empty password, so it carries no credentials. Some directories let anonymous clients read public information such as office phone numbers. Harbor's allows them nothing, because even a list of staff names and job titles tells an outsider whom to target.
A simple bind with credentials sends a DN and a password. The password travels exactly as it was typed, so anyone who can observe the connection can read it unless the connection is protected with TLS. There are two ways to add that protection. LDAPS starts TLS before any LDAP message is sent, usually on port 636. StartTLS begins with an ordinary connection, usually on port 389, and upgrades it with an operation defined in RFC 4511 before the bind is sent. With StartTLS, a client should refuse to continue if the upgrade fails. Either way, the client must check the server's certificate, for the reasons described in Protecting credentials and messages.
Simple binds have one more trap. A simple bind with a DN and an empty password is treated as an unauthenticated bind, and a server configured to accept those reports success without checking anything. An application that takes a successful bind to mean "the password was correct" must reject an empty password before it binds.
A SASL bind uses the Simple Authentication and Security Layer, a framework for plugging in other authentication mechanisms. It lets a client authenticate with a Kerberos ticket, for example, or with the certificate it presented while setting up TLS, so no password is sent to the directory at all.
Applications usually bind as themselves, using a service account. Scheduling binds as uid=svc-scheduling,ou=services,dc=harborclinic,dc=example, which may read names, managers, and group memberships under ou=staff and ou=groups, and nothing else. Older applications that check passwords against the directory do it in two steps. They bind as their service account and search for the DN of the person who typed dana.okafor, then bind again as that DN with the password Dana typed. If the second bind succeeds, the password was correct. The service account's own password is a credential like any other: it needs an owner, careful storage, and replacement when someone who knew it leaves.
LDAP and modern applications
Many organizations still run LDAP directories internally, and plenty of software expects one: internal web applications, network equipment, servers that look up who may sign in to them, and identity providers that keep their users in a directory. Even when Dana signs in through federation, the identity provider may check Dana's password with an LDAP bind behind the scenes.
Newer applications more often take a different path. They accept sign-ins through federation, as described in Trust across systems, and receive account changes through SCIM, which SCIM users, groups, and schemas introduces. That keeps passwords out of applications and removes their need to connect directly to the directory.
Wherever an application builds a filter from user input, one mistake keeps recurring. Consider a sign-in page that looks up the entry for a typed user name like this:
filter = "(&(objectClass=inetOrgPerson)(uid=" + userName + "))"
Typed user name: *
Filter sent: (&(objectClass=inetOrgPerson)(uid=*))
Result: every person in the directory
Typed user name: dana.okafor)(employeeNumber=E1*
Filter sent: (&(objectClass=inetOrgPerson)(uid=dana.okafor)(employeeNumber=E1*))
Result: Dana's entry, but only if Dana's employee number starts with E1
This is filter injection. The first input turns a lookup for one person into a list of everyone, and a page that expects one match and takes the first might act on the wrong person's entry. The second input adds a condition the developer never wrote. By watching whether the page behaves as though the user exists, someone can learn Dana's employee number one character at a time, and the same trick works for any attribute the application's service account can read.
The fix is to escape the input before it goes into the filter. RFC 4515 defines how: each special character is replaced by a backslash and its two-digit hexadecimal code.
| Character | Escaped as |
|---|---|
* | \2a |
( | \28 |
) | \29 |
\ | \5c |
| NUL (the zero byte) | \00 |
filter = "(&(objectClass=inetOrgPerson)(uid=" + escapeFilterValue(userName) + "))"
Typed user name: *
Filter sent: (&(objectClass=inetOrgPerson)(uid=\2a))
Result: no entry, unless someone's user name really is *
Typed user name: dana.okafor)(employeeNumber=E1*
Filter sent: (&(objectClass=inetOrgPerson)(uid=dana.okafor\29\28employeeNumber=E1\2a))
Result: no entry
Use the escaping function your LDAP library provides rather than writing your own, and use the right one for the job. Values placed in a DN follow the different rules in RFC 4514, where a comma, for example, must be escaped. Checking that a user name contains only the characters the clinic's naming rules allow adds a second layer, and a service account that can read only what the application needs limits what any injection could reveal.
All of this comes into play the moment HR records a new hire. Continue to Joining an organization to follow Dana from an HR hire record to working access on the first day.