Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix LDAP filter injection in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

On this page

Steps

  1. 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.
  2. Keep the filter template a constant, for example (&(objectClass=person)(employeeType=staff)(cn={cn})), and fill only the value positions.
  3. 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.
  4. 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.
  5. 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.
  6. Reject or log a malformed filter server-side and return a generic error; do not echo the rejected value.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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 name

With IMPL=vulnerable, 3 of 7 tests fail (2 exploit tests).

The fix

Fixedimpl/fixed.pyPython
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

Wrong fiximpl/wrong.pyPython
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 regression test

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.

Testtests/test_directory.pyPython
# --- 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"] == []

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.
  4. 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.

Scanner rules

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.

Scanner rules for Fix LDAP filter injection in Python and Flask
ToolRuleWhat it found here
Semgrepp/pythonNo 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)
CodeQLpy/ldap-injectionExpected 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
Banditn/aNo LDAP injection pluginNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
ldap32.9.1
pyasn1 (ldap3's ASN.1 codec)0.6.4
werkzeug3.1.9
pytest9.1.1

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 18 KB

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

  1. OWASP LDAP Injection Prevention Cheat Sheet
  2. RFC 4515: LDAP String Representation of Search Filters (section 3, escaping)
  3. ldap3 documentation: escape_filter_chars and the Mock strategy
  4. ldap3 source: escape_filter_chars (utils/conv.py)
  5. CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
  6. CAPEC-136: LDAP Injection
  7. CodeQL query help: py/ldap-injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.