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. Keep the filter template constant. DN escaping (escape_rdn) is for distinguished names and does not neutralize a filter.
- Find every LDAP search whose filter is built from request data (request.args, request.form, request.json): Connection.search in ldap3, or search_s in python-ldap.
- Keep the filter template a constant, for example (&(objectClass=person)(employeeType=staff)(cn={cn})), and fill only the value positions.
- Pass each untrusted value through ldap3.utils.conv.escape_filter_chars before it reaches the filter. It encodes *, (, ), backslash and NUL as \2a, \28, \29, \5c and \00 per RFC 4515.
- Use escape_dn_chars / escape_rdn only for the parts of a distinguished name (the search base or an add/modify DN), never for a filter value; the two escape different character sets.
- Return only the attributes the feature needs, and scope the search with the restrictions the feature requires (here employeeType=staff), so a widened filter still cannot reach accounts or attributes it should not.
- Reject or log a malformed filter server-side and return a generic error; do not echo the rejected value.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def filter_value(name):
# The name is pasted into (cn=<name>) unchanged. "*" becomes a wildcard that lists every
# staff member, ")" closes the cn assertion early, and "(mail=a*)" smuggles in an extra
# clause that reads an attribute the search never returns.
return nameWith IMPL=vulnerable, 3 of 7 tests fail (2 exploit tests).
The fix
def filter_value(name):
# escape_filter_chars encodes the RFC 4515 metacharacters as \\2a \\28 \\29 \\5c \\00, so
# "*" matches a literal asterisk and ")(mail=a*" can only match a cn that really contains
# that text. The filter structure the application wrote cannot be changed by the input.
return escape_filter_chars(name)With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
def filter_value(name):
# Escapes "(" and ")" but not "*" (nor "\\"). ")(mail=a*" can no longer add a clause, yet a
# plain "*" still reaches the filter as a wildcard and lists every staff member. RFC 4515
# requires escaping *, (, ), \\ and NUL; a partial list is not enough.
return name.replace("(", "\\28").replace(")", "\\29")With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# --- Exploit: filter metacharacters must not change which entries match. ---
def test_exploit_wildcard_does_not_list_the_whole_directory(client):
# A single "*" asks the directory for everyone. A safe search treats it as a literal name,
# so it matches nobody; the vulnerable and parens-only implementations return every staffer.
status, body = results(client, "*")
assert status == 200, body
assert body["results"] == [], f"{IMPL} leaked the staff directory: {body['results']}"
def test_exploit_blind_attribute_oracle_reveals_nothing(client):
# The payload closes the cn assertion and adds (mail=<prefix>*), probing an attribute the
# search never returns. If the two prefixes give different results, the attacker has learned
# a character of Alice's hidden email. A safe search returns the same (empty) result for both.
_, starts_with_a = results(client, "Alice Martin)(mail=a*")
_, starts_with_z = results(client, "Alice Martin)(mail=z*")
assert starts_with_a["results"] == starts_with_z["results"], (
f"{IMPL} is a blind oracle: 'a*' -> {starts_with_a['results']}, "
f"'z*' -> {starts_with_z['results']}"
)
assert starts_with_a["results"] == []- escape_rdn (and escape_dn_chars) escape the DN set (\ , + " < > ; = NUL) and leave *, ( and ) untouched, so using them on a filter value still lets * list every entry. Filters need escape_filter_chars.
- A blocklist that strips or escapes only ( and ) stops clause injection but leaves * alive, so a lone * still turns a name lookup into a full directory listing, and a substring payload still works as a blind oracle.
- A value does not need unbalanced parentheses to be dangerous: a single * is a present/substring wildcard, and
name)(attr=prefix*adds a balanced clause that reads an attribute the search never returns, one character at a time. - ldap3's own RDN helper is right for building the entry DN when you seed or write data, but that is a different place from the search filter; do not reuse it there.
- Scope matters as much as escaping: without the employeeType=staff clause, even a correctly escaped name search would expose service and disabled accounts once the attacker knows a name.
- escape_filter_chars expects text; for raw bytes it falls back to byte escaping. Decode input to str first so the intended encoding is applied.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ldap-injection/python-flask does this for you).
- The exploit tests assert that a lone * matches nobody (no directory listing) and that
Alice Martin)(mail=a*and...)(mail=z*return the same empty result (no blind oracle). Functional tests keep exact names, names containing parentheses (Jo Smith (Contractor)) or an asterisk (Star*Light) and no-match searches working, and keep non-staff accounts out. - ldap3's MOCK_SYNC strategy parses the filter with its real RFC 4515 parser and evaluates &, |, !, =, presence and substring against in-memory entries, so an unescaped metacharacter changes the result exactly as a live directory would; no network or server is used.
- Against your own directory, search for * and for name)(userPassword=* and confirm you get your own single result or an error, not the whole directory or a value you never query.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python | No findings on any implementation. The Semgrep registry has no Python LDAP-injection rule: semgrep-rules (develop at 9bbf021) has LDAP rules only for C#, Java, Kotlin and PHP. The card first cited python.lang.security.audit.ldap-injection.ldap-injection, which does not exist, so it is removed. The regression tests are the control.Checked: Semgrep 1.180.0 (CE); semgrep scan --no-git-ignore --config p/python impl app.py lib (151 rules, 7 files) |
| CodeQL | py/ldap-injection | Expected to flag the vulnerable and wrong implementations and clear the fixed one. semmle/python/frameworks/Ldap3.qll models ldap3 Connection.search as the LDAP filter sink, escape_filter_chars as the filter sanitizer and escape_rdn as a DN-only sanitizer, and CodeQL tracks taint across functions, so it follows request.args through impl.filter_value into lib.directory. The fixed code's escape_filter_chars clears the filter taint; the wrong code's parenthesis-only replacement is not a recognized sanitizer, so the path remains.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo LDAP injection plugin | No issues identified. Bandit has no LDAP-injection (CWE-90) plugin, so it does not evaluate this card. The three-mode regression suite is the enforcement evidence.Checked: Bandit 1.9.4; bandit -r impl app.py lib |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| ldap3 | 2.9.1 |
| pyasn1 (ldap3's ASN.1 codec) | 0.6.4 |
| werkzeug | 3.1.9 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ldap-injection/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ldap-injection/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- flask 3.1.3
- ldap3 2.9.1
- pyasn1 0.6.4
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 3 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
- Test results
- fixed 7/7 pass; vulnerable 3 fail (2 exploit: the wildcard listing and the blind oracle, plus the parenthesis name it can no longer parse); wrong 2 fail (2 exploit: the parenthesis-only blocklist leaves * and the oracle alive), offline after install
- Dependency freshness
- ldap3 2.9.1 is the current stable release (2021-07-18); its only newer tags are 2.10.2 release candidates and the project's last commit is 2026-04-18, so the card pins the last stable. pyasn1 0.6.4 (2026-07-09) fixes CVE-2026-23490, CVE-2026-30922, CVE-2026-59884/59885/59886 (decoder denial-of-service), so the card pins it above ldap3's floor of 0.4.6. OSV listed no advisory for ldap3 itself on 2026-10-10. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP LDAP Injection Prevention Cheat Sheet
- RFC 4515: LDAP String Representation of Search Filters (section 3, escaping)
- ldap3 documentation: escape_filter_chars and the Mock strategy
- ldap3 source: escape_filter_chars (utils/conv.py)
- CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
- CAPEC-136: LDAP Injection
- CodeQL query help: py/ldap-injection
Attack patterns: