Choose your stack
Fix LDAP filter injection in Python and Flask
Escape every value that goes into an LDAP filter with ldap3.utils.conv.escape_filter_chars, which encodes the RFC 4515 metacharacters (* ( ) \ NUL) so they are matched as data, not parsed as syntax.
Open the fix card Java · Spring BootFix LDAP filter injection in Java and Spring Boot
Build the filter with Spring LDAP's LdapQueryBuilder (query().where("cn").is(name)), which puts every value through LdapEncoder.filterEncode (RFC 4515), so * ( ) \ and NUL are matched as data, not parsed as syntax.
Open the fix cardStacks without a card
- Node.js and Express: no card yet. The nearest is Fix LDAP filter injection in Python and Flask.
- C# and ASP.NET Core: no card yet. The nearest is Fix LDAP filter injection in Java and Spring Boot.
- TypeScript and Next.js: no card yet. The nearest is Fix LDAP filter injection in Python and Flask.
- Python and FastAPI: no card yet. The nearest is Fix LDAP filter injection in Python and Flask, in the same language.
- Python and Django: no card yet. The nearest is Fix LDAP filter injection in Python and Flask, in the same language.
- Go and net/http: a card is planned (coverage row APP-01).
- Ruby on Rails: a card is planned (coverage row APP-01).
- PHP and Laravel: a card is planned (coverage row APP-01).
How it happens
- The filter string is built by concatenation or interpolation, so request data is parsed as LDAP filter syntax. cn=(user input) lets the input add operators, clauses and wildcards.
- A lone * is a presence/substring wildcard: a search meant to find one named person returns every entry the rest of the filter allows, disclosing the whole staff list.
- A value such as
Alice)(mail=a*closes the cn assertion and adds a second, balanced clause. Because it stays a single valid filter, a strict RFC 4515 parser accepts it, and the attacker learns an attribute the feature never returns by asking yes/no questions one character at a time (a blind oracle). - Hand-made fixes escape the wrong characters. DN escaping (escape_rdn, LdapEncoder.nameEncode) is for distinguished names and leaves *, ( and ) untouched; a blocklist of only parentheses still lets * through.
- The search is scoped too loosely, so a widened or injected filter reaches service accounts, disabled accounts or attributes that should never have been in range.
Fix principles
- Keep the filter template constant and fill only value positions; never build the filter by pasting request data into it.
- Escape every untrusted value with the library's RFC 4515 filter helper (ldap3.utils.conv.escape_filter_chars, Spring LDAP's LdapEncoder.filterEncode or a LdapQueryBuilder parameter), which encodes * ( ) \ and NUL as data.
- Use DN escaping (escape_rdn, escape_dn_chars, LdapEncoder.nameEncode) only for the parts of a distinguished name, never for a filter value: the two escape different character sets.
- Scope the search to what the feature needs (object class, an employeeType or group restriction, a narrow base DN) so a widened filter still cannot reach accounts or subtrees it should not.
- Return only the attributes the feature needs, so an injected clause cannot be used to read a value the response was never meant to expose.
- Reject or log a malformed filter server-side and return a generic error; never echo the rejected value.
The scenario
Every card runs the same staff directory. GET /directory?name= searches an LDAP directory with the filter (&(objectClass=person)(employeeType=staff)(cn=<name>)), so it lists only staff whose common name matches, and <name> is the one untrusted part. The directory also holds service and disabled accounts and a mail attribute the search never returns; the exploits try to reach them. The tests run against an in-process directory that parses and evaluates the real filter grammar (ldap3's MOCK_SYNC strategy in Python; an UnboundID in-memory directory server in Java), so an unescaped metacharacter changes which entries match exactly as a live directory would, with no network or server.
How the cards prove it
- Run each card's tests in three modes: the fixed implementation passes, and the vulnerable and plausible-but-wrong implementations fail. run-all.mjs does this for every card.
- The exploit tests assert that a lone * matches nobody (no directory listing) and that two blind-oracle probes that differ only in a guessed character return the same result (no attribute leak). Functional tests keep exact names, names containing parentheses or an asterisk, and no-match searches working, and keep non-staff accounts out.
- Static analysis is uneven. CodeQL models the ldap3 and Spring LDAP search sinks and their filter sanitizers (read, not executed here), so it can flag the vulnerable and wrong implementations; Semgrep CE's nearest rule is file-local and did not fire, and Bandit has no LDAP-injection plugin. Each card's scannerRules records exactly what ran and what it found.
Identifiers
CWE-90 (Improper Neutralization of Special Elements used in an LDAP Query) is the weakness; OWASP Top 10:2025 folds injection into A05. CAPEC-136 (LDAP Injection) is the attack pattern MITRE links to CWE-90. ASVS 5.0.0 1.2.6 requires that the application protect against LDAP injection or implement specific controls to prevent it.
Attack patterns:
References
- OWASP LDAP Injection Prevention Cheat Sheet
- RFC 4515: LDAP String Representation of Search Filters
- CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
- CAPEC-136: LDAP Injection
- OWASP ASVS 5.0.0 V1 Encoding and Sanitization (1.2.6, LDAP injection)
- PortSwigger issue definition: LDAP injection
Guides on this weakness
Keep untrusted values out of executable syntax
Choose a safe interface for each interpreter and bound the work performed by parsers and converters.
Prevent NoSQL, LDAP, XPath and expression-language injection
SQL is not the only query language that receives user input. Stop MongoDB operator injection with type checks and $eq, escape LDAP filter values, pass XPath values as variables, and never evaluate SpEL or OGNL built from request data.