Mesrai
Back to blog
// essaySecurity

LDAP Injection in Auth Handlers: A Diff-by-Diff Walkthrough

Real Mesrai catch on an LDAP login flow: filter built via template literal — `*)(uid=*` bypasses auth. CWE-90 walkthrough with attack + canonical fix.

Mesrai TeamJuly 19, 20269 min read

An internal admin tool wired up against the corporate LDAP directory. The author's first pass at the bind logic put the username and password into the filter string. Mesrai's review comment came in about a minute later.

The vulnerable diff

tsauth/ldap-login.ts
// auth/ldap-login.ts
import { createClient } from "ldapjs";

const client = createClient({ url: "ldaps://corp.example" });

export async function authenticate(username: string, password: string) {
  // BUG: filter built from raw user input
  const filter = `(&(uid=${username})(userPassword=${password}))`;
  const entries = await client.search("ou=users,dc=example", { filter });
  return entries.length > 0;
}

What is wrong

LDAP filters have their own metacharacters: `(`, `)`, `*`, `\`, NUL. Untrusted input interpolated into a filter string can change the logical structure of the filter — including turning an AND into an OR-anything. This is CWE-90, LDAP Injection. The classic exploit is well-documented and works against any directory that does not pre-escape user input.

There is a second bug in the same line. Putting the password inside the filter and then asking the directory to return matching entries is not how LDAP authentication is supposed to work. The correct pattern is to search by username, then `bind` with the returned DN and the user's password — let the directory enforce the password check, do not infer it from filter matches.

The attack

textexploit.txt
username = "*)(uid=*"
password = "anything"

# Resulting filter:
(&(uid=*)(uid=*)(userPassword=anything))

# That matches every user (`uid=*` is a wildcard). Length > 0,
# so the function returns true. Auth bypassed without knowing
# any password.

Even without the AND-becomes-OR trick, the second `uid=*` after the closing paren of the original filter neutralizes the password check entirely. A more advanced variant — `*)(|(objectClass=*))(uid=*` — works against directories with stricter filter parsing.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Security] [CWE-90] [critical]

Two issues on the same line.

(1) LDAP injection. The filter is built from raw user input. Special
characters `( ) * \` in `username` change the filter structure. With
`username = "*)(uid=*"` the resulting filter matches every user and
the function returns true regardless of password.

(2) Even after escaping, you should not put the password inside an
LDAP filter. Search by username only, then `bind` with the returned
DN and the user's password — let the directory enforce the password
check.

Fix:

  import { filter as ldapFilter } from "ldap-escape";

  const searchFilter = ldapFilter`(uid=${username})`;
  const entries = await client.search("dc=example", { filter: searchFilter });
  if (entries.length !== 1) throw new Error("auth failed");
  await client.bind(entries[0].dn, password); // throws on bad password

Reference: OWASP LDAP Injection Prevention Cheat Sheet, CWE-90

The fix

tsauth/ldap-login.ts (fixed)
// auth/ldap-login.ts — fixed
import { createClient } from "ldapjs";
import { filter as ldapFilter } from "ldap-escape";

export async function authenticate(username: string, password: string) {
  const client = createClient({ url: "ldaps://corp.example" });
  try {
    // 1. Search by username only — escape filter components
    const searchFilter = ldapFilter`(uid=${username})`;
    const entries = await client.search("ou=users,dc=example", {
      filter: searchFilter,
      scope: "sub",
    });
    if (entries.length !== 1) return false;

    // 2. Bind with the user's DN + password — directory enforces it
    await client.bind(entries[0].dn, password);
    return true;
  } catch {
    return false;
  } finally {
    client.unbind();
  }
}

Two-step bind is the canonical LDAP auth flow. The directory enforces the password check; the application never has to compare passwords, never embeds them in filters, and never has to trust the result of a search-with-password-filter pattern.

Why human review missed it

LDAP-specific knowledge is rare outside identity-engineering teams. The filter syntax looks like a small DSL that the reviewer's eye skips over. And the search-then-bind pattern is documented in the `ldapjs` README but easy to miss if the original author was working off internal lore. Mesrai's rule pack catches the pattern because the rule is explicit: any LDAP filter built with template literals over input that crosses a trust boundary is unsafe.

Related rules + further reading

Mesrai rule pack: security/no-ldap-filter-interp — flags LDAP filters built via string concatenation or template literals.

OWASP Cheat Sheet: LDAP Injection Prevention.

CWE-90 — Improper Neutralization of Special Elements used in an LDAP Query.

Takeaway

Two rules close the entire LDAP-injection class. Escape filter components with a library that knows the spec. Authenticate by binding, never by filtering on the password. Mesrai flags both halves.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.