
CVE-2021-41232 scored 8.1. Thunderdome interpolated the login name into an LDAP filter. The GitHub advisory said the username was not escaped. CWE-90 still lists that CVE on the page I opened, last updated 30 April 2026.
This page is the boundary: a typed value, an escaped assertion, a hard-coded filter template, and a size cap so a wide match cannot become “success.”
LDAP injection is one interpreter in the injection family. The login name still needs an allowlist on the way in. That is input validation. The password check and the session you mint afterwards sit on secure authentication.
You write the filter. The username is only the escaped assertion that fills the hole. Parentheses and wildcards never author the grammar.
SecureCoding
CWE-90 is grammar, not a password
CWE-90 is “Improper Neutralization of Special Elements used in an LDAP Query.” I opened the MITRE entry (CWE 4.20). The product builds all or part of a query from upstream input and does not neutralize the characters the directory will treat as syntax. Observed examples on that page include Thunderdome and older directory engines that concatenated a search string. The entry was last updated 30 April 2026. The escape rules did not get a 2026 rewrite. RFC 4515 is still June 2006.
A directory is an interpreter. The filter language has AND, OR, NOT, parentheses, and a wildcard. The DN language has commas, plus signs, and quotes. If a login field can change either grammar, the directory answers a different question than the one you wrote. The password bind may still run. It may run as a different object. That is the whole class.
I could not confirm a 2025-2026 CWE-90 CVE with a first-party advisory I opened for this page. Grep the interpolations. The Thunderdome fix was ldap.EscapeFilter on the username. That is the control.
Bind is not a filter
Two calls get confused in login code.
- Bind proves a password for one DN. The DN must already be a name you resolved, not a string the user typed as grammar.
- Search finds entries with a filter. The assertion values come from the user. The filter template comes from you.
The safe login shape is service-account bind, search with an escaped equality on uid or mail, require exactly one entry, then bind as that entry’s DN with the password the user typed. If the search returns zero or more than one, fail closed. Do not bind with "uid=" + username + ",ou=people,dc=example,dc=com". That line makes the user a DN author.
Escape per RFC 4515
RFC 4515 is the string representation of search filters. The octets that must be escaped in an assertion value are NUL (\00), left paren (\28), right paren (\29), asterisk (\2a), and backslash (\5c). Other octets may be escaped the same way. UTF-8 characters are escaped per octet, not per code point.
Do not write that table by hand in every handler. I opened the ldapts 9.0.0 readme (npm, last updated 11 July 2026). The tagged template escapeFilter escapes every interpolated value and leaves the filter syntax you wrote alone. Filter.escape is the same function for a single value. Use the tag so a later author cannot concatenate inside the template by mistake.
import { Client, escapeFilter } from "ldapts";
const USER_OK = /^[A-Za-z0-9._-]{1,64}$/;
export async function lookupUser(username) {
if (!USER_OK.test(username)) {
return null;
}
const client = new Client({ url: process.env.LDAP_URL });
await client.bind(process.env.LDAP_BIND_DN, process.env.LDAP_BIND_PW);
const filter = escapeFilter`(uid=${username})`;
const { searchEntries } = await client.search("ou=people,dc=example,dc=com", {
filter,
scope: "sub",
sizeLimit: 1,
timeLimit: 5,
attributes: ["dn", "uid", "mail"],
});
await client.unbind();
if (searchEntries.length !== 1) {
return null;
}
return searchEntries[0];
}
Identifiers on this page stay lookupUser, username, filter, and escapeFilter. A second helper that builds a group filter uses the same tag: escapeFilter`(&(objectClass=group)(cn=${groupName}))`. The ampersand and the parentheses in that template are yours. groupName is escaped.
Go’s ldap.EscapeFilter is the Thunderdome fix. Java’s UnboundID and ldaptive have Filter.encodeValue / Filter.escape. Python’s ldap3 has escape_filter_chars. If the library has no helper, copy RFC 4515 into one module and make every handler import it. Do not sprinkle hex replaces next to the query.
Distinguished names are a second grammar
RFC 4514 is the string representation of distinguished names. It is not RFC 4515. Comma, plus, quote, backslash, less-than, greater-than, semicolon, leading or trailing space, and a leading # are the characters that change a DN. Escaping a filter value does not make a DN safe. Building `uid=${username},ou=people,dc=example,dc=com` is the miss even after Filter.escape, because the comma rules differ.
The same library documents filter escaping. I could not confirm a first-party DN escape helper on that readme. Do not invent a DN from user text. Resolve the DN from a search you already size-limited, or look it up from a store you wrote. If a product feature must accept a DN, parse it with a DN library and reject anything that is not one RDN of an allowed attribute under a base you own.
export async function bindAsUser(entryDn, password) {
const client = new Client({ url: process.env.LDAP_URL });
try {
await client.bind(entryDn, password);
return true;
} catch (err) {
return false;
} finally {
await client.unbind();
}
}
export async function login(username, password) {
const entry = await lookupUser(username);
if (!entry) {
return null;
}
const ok = await bindAsUser(entry.dn, password);
if (!ok) {
return null;
}
return { uid: entry.uid, dn: entry.dn };
}
entry.dn came from the directory, after a one-row search. The password never enters a filter. A failed bind and a missing user return the same null to the client so the login form cannot probe existence beyond what you already allow.
sizelimit and the one-row lookup
viraptor’s comparison holds. A SQL auth query that can return two rows is a bug. An LDAP auth search that can return two entries is the same bug. sizeLimit: 1 tells the server to stop. Your code still checks searchEntries.length !== 1. Both layers. A directory that ignores the control is why the length check exists.
Also set timeLimit. A wide filter on a large forest is a denial-of-service even when the characters are escaped. Auth paths do not need subtree scans of every attribute. Ask for dn, uid, mail. Leave * and + operational attributes off the list.
Search versus present: an equality on a specific uid is the auth filter. A presence filter (uid=*) is an inventory query. Do not take a user field and decide “empty means presence.” Empty means reject.
Anonymous bind on a production user tree is a separate misconfiguration. The service account used for lookupUser should read the attributes you listed and nothing else. The user bind in bindAsUser is the password check. Do not reuse the service password for that call.
Talk to the directory on LDAPS or StartTLS. A filter you escaped still travels on the wire with a username. Plain ldap:// on a corporate network is a credential leak, not an injection problem, and it still belongs on the same review. Pin the CA. Do not set a TLS reject-unauthorized flag to false to “make staging work.”
Group checks are a second search. Hard-code the group base and the object class. Escape the group name if it is a product field. Do not take a client-supplied filter string, even from an internal admin UI, and pass it to client.search. An admin UI that lets people type filter grammar is a directory console. Put that on a jump host, not in the app.
The OWASP LDAP Injection Prevention Cheat Sheet is the first-party checklist I opened for this page. It splits escaping for filters from escaping for DNs and tells you not to use the user string as either grammar. That is the same split as the diagram.
Grep the interpolations
You are not probing a directory. You are proving your helper escaped a reserved octet and that login code has no string-plus filter. Point the tests at a directory you own, or stub the client. Do not aim these checks at a forest you do not operate.
rg -n "filter\\s*[:=].*(\\+|`|\\$\\{)" --glob '!node_modules'
rg -n "uid=\\$|uid=\" \\+|EscapeFilter|escapeFilter|escape_filter" \
--glob '!node_modules'
rg -n "sizeLimit|sizelimit|SizeLimit" --glob '!node_modules'
A hit that builds filter with + or an untagged template is the review. A login file with no escapeFilter / EscapeFilter / escape_filter_chars is the Thunderdome shape. A search on the auth path with no sizeLimit is the viraptor note.
import { escapeFilter, Filter } from "ldapts";
import { lookupUser } from "./ldapAuth";
test("escapeFilter hex-encodes a reserved octet", () => {
const username = "ada)lee";
const filter = escapeFilter`(uid=${username})`;
expect(filter).toBe("(uid=ada\\29lee)");
expect(Filter.escape("*")).toBe("\\2a");
});
test("lookupUser rejects a name outside the allowlist", async () => {
await expect(lookupUser("ada)lee")).resolves.toBeNull();
await expect(lookupUser("ada lee")).resolves.toBeNull();
});
The first test pins the helper to RFC 4515. The second test pins the allowlist so a reserved octet never reaches the directory on this path. Point lookupUser at a directory you own in CI, or stub client.search and assert it was not called when the allowlist fails.
Questions we keep getting
Is parameterization available for LDAP the way it is for SQL?
Not in the wire protocol the way a prepared statement is. The control is a hard-coded template plus an escaped value, which is what escapeFilter is. Some higher-level APIs take a field map and encode for you. Use those. Do not treat a template literal without a tag as parameterization.
Can I skip escape if I already allowlist usernames?
On the uid field, an allowlist that excludes the five RFC 4515 octets is already a pass. Keep the escape for the next field. Mail local-parts, display names, and group names grow characters the allowlist will not hold.
Does a failed bind mean the filter was safe?
No. A bind result tells you whether one DN accepted a password. It does not tell you the search returned the object you intended. Check the count, then bind. Log the filter template id, not the raw filter string, so a support dump does not reprint assertion values.



