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. Keep the filter template constant. LdapEncoder.nameEncode is DN escaping and does not neutralize a filter.
- Find every LdapTemplate.search (or DirContext.search) whose filter is built from request data by concatenation or String.format.
- Rebuild the query with LdapQueryBuilder: query().where("objectClass").is("person").and("employeeType").is("staff").and("cn").is(name). Each is(...) value is encoded for you.
- If you must pass a filter string, encode each value with org.springframework.ldap.support.LdapEncoder.filterEncode(name) and keep the template constant; never concatenate the raw value.
- Use LdapEncoder.nameEncode / LdapUtils only for the parts of a distinguished name (the search base or an add/modify DN), never for a filter value.
- Scope the search with the restrictions the feature needs (object class, employeeType, a narrow base DN) and return only the attributes it needs, so a widened filter cannot reach accounts or attributes it should not.
- Catch the search exception, return a generic error and log server-side; never echo the rejected filter or 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
String filter = "(&(objectClass=person)(employeeType=staff)(cn=" + name + "))";
return ldap.search(LdapUtils.emptyLdapName(), filter, new CnMapper());With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).
The fix
// query() encodes every is(...) value: "*" becomes \2a, "(" and ")" become \28 \29, so the
// filter structure the application wrote cannot be changed by the input.
return ldap.search(
query()
.where("objectClass").is("person")
.and("employeeType").is("staff")
.and("cn").is(name),
new CnMapper());With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
String encoded = LdapEncoder.nameEncode(name); // DN escaping, wrong for a filter
String filter = "(&(objectClass=person)(employeeType=staff)(cn=" + encoded + "))";
return ldap.search(LdapUtils.emptyLdapName(), filter, new CnMapper());With IMPL=wrong, 1 of 7 tests fail (1 exploit 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.
/** Filter metacharacters must not change which entries match. */
class DirectoryExploitTest extends DirectoryApiBase {
@Test
void wildcardDoesNotListTheWholeDirectory() throws Exception {
// A single "*" asks for everyone. A safe search treats it as a literal name and matches
// nobody; the vulnerable and wrong-encoder implementations return every staff member.
Response response = search("*");
assertThat(response.results())
.as("a wildcard must not list the staff directory")
.isEqualTo(List.of());
}
@Test
void blindAttributeOracleRevealsNothing() throws Exception {
// 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.
List<String> startsWithA = search("Alice Martin)(mail=a*").results();
List<String> startsWithZ = search("Alice Martin)(mail=z*").results();
assertThat(startsWithA)
.as("a filter clause must not become a blind oracle")
.isEqualTo(startsWithZ);
assertThat(startsWithA).isEqualTo(List.of());
}
}- LdapEncoder.nameEncode escapes the DN characters of RFC 4514 (\ , + " < > ; and leading/trailing spaces), not the filter characters of RFC 4515, so it leaves * untouched and a lone * still lists the directory. Filters need filterEncode or LdapQueryBuilder.
- A value does not need unbalanced parentheses to be dangerous: a single * is a wildcard, and
name)(mail=prefix*adds a balanced clause that reads an attribute the search never returns, one character at a time. - LdapQueryBuilder.filter(String) takes a raw filter and does not encode it; only the where(...).is(...) / like(...) builders encode values. Do not hand filter() a string you concatenated.
- LdapTemplate.search(base, filter, mapper) treats filter as raw text; the base is a DN, so the two arguments need different encoders.
- An objectClass clause only restricts entries that actually carry that class value; rely on a positive restriction the directory enforces (here employeeType=staff) plus a narrow base, not on the filter shape alone.
- Returning extra attributes turns a filter-injection oracle into a direct read; map out only the attributes the response needs (here cn).
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ldap-injection/java-spring 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. - The tests run against an UnboundID in-memory directory server on a loopback port, loaded from directory.ldif, which parses and evaluates the real RFC 4515 filter grammar, so an unescaped metacharacter changes the result exactly as a live directory would.
- 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 | java.lang.security.audit.tainted-ldapi-from-http-request.tainted-ldapi-from-http-request | No findings on any implementation. The rule is taint from an HttpServletRequest into a JNDI DirContext or InitialDirContext search; this card takes a Spring @RequestParam and searches with Spring LDAP's LdapTemplate, neither of which the rule models. (The card first cited java.spring.security.injection.tainted-ldap-from-http-request, which is not in the registry, so that earlier 'no findings' meant nothing ran.)Checked: Semgrep 1.180.0 (CE); semgrep scan --no-git-ignore --config r/<id> src/main/java (semgrep-rules develop at 9bbf021) |
| Semgrep | java.lang.security.audit.ldap-injection.ldap-injection | No findings on any implementation. The rule matches a non-constant filter passed to search on a JNDI context (DirContext, LdapContext and similar) declared in the same method; the card's LdapTemplate is not one of them.Checked: Semgrep 1.180.0 (CE); semgrep scan --no-git-ignore --config r/<id> src/main/java |
| Semgrep | p/java | No findings on any implementation. The registry's Java LDAP rules model JNDI contexts and HttpServletRequest, not Spring LDAP's LdapTemplate or a @RequestParam, so nothing in the pack reaches this card's filter.Checked: Semgrep 1.180.0 (CE); semgrep scan --no-git-ignore --config p/java src/main/java (run together with the two rules above: 61 rules, 8 files) |
| CodeQL | java/ldap-injection | Expected to flag the vulnerable implementation and clear the fixed one. semmle/code/java/security/LdapInjection.qll models Spring LDAP (LdapTemplate searches and LdapQueryBuilder.filter/base) and UnboundID SearchRequest as LDAP sinks and tracks taint across methods, so it follows the @RequestParam name through DirectorySearch into the raw concatenated filter. The fixed code passes the value through LdapQueryBuilder's is(...) (LdapEncoder.filterEncode), a recognized encoding step; the wrong code's LdapEncoder.nameEncode is DN escaping, not a filter sanitizer, so a path would remain.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs) | LDAP_INJECTIONPotential LDAP Injection | Its description matches this card's vulnerable code: a DirContext/LdapTemplate search whose filter string is built from untrusted input. It would not flag the fixed code, which never concatenates the value into the filter.Checked: Rule read from the Find Security Bugs bug patterns; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, Spring LDAP 4.1.1) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-data-ldap (Spring LDAP, LdapTemplate, LdapQueryBuilder, LdapEncoder) | 4.1.1 (spring-ldap-core 4.1.1) |
| com.unboundid:unboundid-ldapsdk (the in-memory directory the card searches) | 7.0.5 |
| org.springframework.boot:spring-boot-starter-webmvc-test (test only) | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot, test only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ldap-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs ldap-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- jdk OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- springLdap 4.1.1
- unboundIdLdapSdk 7.0.5
- tomcat 11.0.26
- jackson 3.1.7
- junitJupiter 6.0.3
- semgrep 1.180.0
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 1 of 7 tests fail (1 exploit test)
- Test results
- fixed 7/7 pass; vulnerable 2 fail (2 exploit: the wildcard listing and the blind oracle); wrong 1 fail (1 exploit: the parenthesis and name-encode fix leaves * alive, so the wildcard still lists everyone), offline after install
- Dependency freshness
- Spring Boot 4.1.1 was published on 2026-08-20 and manages Spring LDAP 4.1.1 and UnboundID 7.0.5. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which have advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7, so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (2026-09-21), as the other Java cards do. Spring LDAP's CVE-2026-41720 (empty-password authentication bypass) is fixed in 4.0.4; the Boot-managed 4.1.1 is above it, and this card does no authentication. Maven has no lockfile; the Boot parent and the two overrides fix every version. .mvn/maven.config pins the file-lock sync context because several agents run Maven at once.
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)
- Spring LDAP reference: building LDAP queries (LdapQueryBuilder)
- Spring LDAP API: LdapEncoder.filterEncode and nameEncode
- UnboundID LDAP SDK: InMemoryDirectoryServer
- CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
- CAPEC-136: LDAP Injection
- CodeQL query help: java/ldap-injection
- Find Security Bugs: LDAP_INJECTION
Attack patterns: