Browse docs

LDAP injection

How unescaped input changes the meaning of an LDAP filter or DN, with a concrete before-and-after example.

On this page

LDAP injection is the same class of bug as SQL injection: untrusted input is combined with a filter or DN string without escaping, and an attacker crafts input that changes the meaning of that string instead of just supplying a value.

A vulnerable filter

Imagine a login form that builds a filter like this, directly from user input:

ts
// Vulnerable — do not do this
const filter = `(&(objectClass=user)(uid=${username}))`;

For a normal username, this works exactly as intended:

(&(objectClass=user)(uid=jdoe))
  • AND
    • objectClass equals user
    • uid equals jdoe
The attack

LDAP filter syntax uses *, (, and ) as structural characters (see LDAP filters). If username isn't escaped, an attacker can include those characters to inject additional filter clauses. Supplying a username of:

text
*)(uid=*))(|(uid=*

turns the intended filter into:

text
(&(objectClass=user)(uid=*)(uid=*))(|(uid=*)))

The carefully placed parentheses close the original uid clause early and open a new | (OR) clause that matches essentially any entry — potentially bypassing whatever the filter was meant to restrict, depending on how the application uses the result.

Danger

The exact impact depends on how the filtered results are used, but at minimum this pattern can let an attacker retrieve entries they shouldn't be able to enumerate, or bypass an intended restriction embedded in the filter.

The fix: escape every value

Escape any value that isn't a hardcoded, trusted literal before it goes into a filter string. RFC 4515 escaping (also covered in LDAP filters) replaces the structural characters with their hex-escaped form:

ts
// Safe
const filter = `(&(objectClass=user)(uid=${escapeFilterValue(username)}))`;

With escaping applied, the same malicious input becomes an inert, literal value instead of restructuring the filter — every *, (, and ) in it is replaced with its hex-escaped form (\2a, \28, \29):

text
(&(objectClass=user)(uid=\2a\29\28uid=\2a\29\29\28\7c\28uid=\2a))

The directory reads that whole escaped sequence as one literal uid value to compare against, not as filter syntax — so it simply matches nothing, instead of matching everything.

Never implement this escaping by hand inline — use a tested helper function, like the escapeFilterValue used throughout the Node.js section.

The same applies to DNs

Building a DN from untrusted input has the same risk, using , and + as the structural characters instead of *(). See Distinguished names for DN escaping.

What's next

Continue to the Production checklist to review everything together before shipping.