Skip to content

CWE-90: Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection') - JavaScript

Overview

LDAP injection in Node.js applications happens when untrusted input is used to build LDAP search filters or Distinguished Names (DNs) via string concatenation or template literals. No Node.js LDAP client escapes the filter string you hand it - ldapts ships the escaping helpers, but calling them is the application's responsibility.

Primary Defence: Validate input against a strict allowlist, build filters with ldapts's escapeFilter tagged template (RFC 4515) and DNs with its DN class (RFC 4514), never accept wildcards (*) in authentication queries, and verify passwords with a bind rather than a filter.

Node.js LDAP libraries: ldapts (TypeScript-first, promise-based, actively maintained) is the current default. ldapjs was decommissioned by its maintainers and its npm package now carries a deprecation notice, so code importing it is migration work rather than a pattern to copy; passport-ldapauth and activedirectory2 are built on it and inherit that. If you are on a client other than ldapts, ldapts's Filter.escape() is a standalone function and can escape values for any of them.

Common Vulnerable Patterns

String Concatenation in a Search Filter

// VULNERABLE - user input directly in the filter
const { Client } = require('ldapts');

async function authenticateUser(username, password) {
    const client = new Client({ url: 'ldap://localhost:389' });
    await client.bind('cn=service,dc=example,dc=com', process.env.LDAP_SERVICE_PASSWORD);

    const filter = `(uid=${username})`; // VULNERABLE

    const { searchEntries } = await client.search('ou=users,dc=example,dc=com', { filter, scope: 'sub' });
    await client.unbind();
    if (!searchEntries.length) return false;

    const userClient = new Client({ url: 'ldap://localhost:389' });
    try {
        await userClient.bind(searchEntries[0].dn, password);
        return true;
    } catch {
        return false;
    } finally {
        await userClient.unbind();
    }
}

// Attack: username = "*"
// Resulting filter: (uid=*) - matches every entry, so the code goes on to bind as whichever
// account the directory returns first. The bind still checks the password, so what the
// attacker gains here is control over which account is probed plus a directory enumeration
// primitive - not a password-free login. The next pattern is where the bypass is real.

Why this is vulnerable: An LDAP filter is a parenthesised expression tree, not a flat string, so the metacharacters an attacker needs are structural: ) closes the current term and (| opens an OR that is trivially satisfied. * alone turns an equality test into a wildcard, which is why (uid=*) matches every entry and authentication checks written as filters can be answered without a password.

Filters and distinguished names need different escaping and this is where a partial fix usually goes wrong. RFC 4515 governs filter values - escape *, (, ), \ and NUL as \XX hex - while RFC 4514 governs DN components, where the significant characters are ,, +, ", \, <, >, ;, =, NUL, a leading #, and a leading or trailing space. An encoder written for one leaves the other injectable.

Password Compared Inside the Filter

// VULNERABLE - the credential check is filter syntax, so injection decides the login
async function authenticateUser(username, password) {
    const client = new Client({ url: 'ldap://localhost:389' });
    await client.bind('cn=service,dc=example,dc=com', process.env.LDAP_SERVICE_PASSWORD);

    const filter = `(&(uid=${username})(userPassword=${password}))`; // VULNERABLE

    const { searchEntries } = await client.search('ou=users,dc=example,dc=com', { filter, scope: 'sub' });
    await client.unbind();
    return searchEntries.length > 0;  // "a match means the password was right"
}

// Attack: username = "admin", password = "*"
// Resulting filter: (&(uid=admin)(userPassword=*)) - `*` is a presence test, so it matches
// any admin account that has a password set at all, and the caller is logged in without
// knowing it. No bind ever happens, so nothing else checks the credential.

Why this is vulnerable: Once the password is part of the filter, filter syntax and the credential check are the same thing, and a metacharacter in either field decides the outcome. Escaping the values closes the injection, but the pattern is still wrong: a directory only answers a userPassword comparison when the bind account can read or compare that attribute, and comparisons are exact-match against whatever hash format the server stores. Verify credentials with a bind, as the secure pattern below does.

DN Injection

// VULNERABLE - user input in a Distinguished Name
async function getUserInfo(orgUnit, userId) {
    const client = new Client({ url: 'ldap://localhost:389' });
    await client.bind('cn=service,dc=example,dc=com', process.env.LDAP_SERVICE_PASSWORD);

    const userDn = `uid=${userId},ou=${orgUnit},dc=example,dc=com`; // VULNERABLE
    const { searchEntries } = await client.search(userDn, { scope: 'base' });
    await client.unbind();
    return searchEntries[0];
}

// Attack: userId = "svc-backup,ou=service-accounts", orgUnit = "users"
// Resulting DN: uid=svc-backup,ou=service-accounts,ou=users,dc=example,dc=com - the injected
// comma adds an RDN, so the lookup addresses an entry in a different OU than the code built
// the DN for

Why these are vulnerable: Template literals and string concatenation don't escape anything - special characters in the input (*, (, ), \ for filters; ,, +, ", \, <, >, ;, = for DNs) are passed through to the LDAP server as syntax, letting an attacker change what the query matches or which object a bind/search targets.

Secure Patterns

escapeFilter and Search-Then-Bind (Primary)

// SECURE - escapeFilter escapes every interpolated value per RFC 4515
const { Client, escapeFilter } = require('ldapts');

const LDAP_URL = 'ldaps://ldap.example.com:636';
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,64}$/;

async function authenticateUser(username, password) {
    // Step 1: allowlist validation - rejects most injection attempts outright
    if (!USERNAME_PATTERN.test(username)) {
        throw new Error('Invalid username format');
    }
    // ldapts sends an empty password as-is - see the note after this example
    if (!password) return false;

    const client = new Client({ url: LDAP_URL, timeout: 5000 });
    let userDn;

    try {
        await client.bind('cn=service,dc=example,dc=com', process.env.LDAP_SERVICE_PASSWORD);

        // Step 2: the tag escapes ${username} only - the filter structure stays as written
        const { searchEntries } = await client.search('ou=users,dc=example,dc=com', {
            filter: escapeFilter`(&(objectClass=person)(uid=${username}))`,
            scope: 'sub',
            attributes: ['1.1'],   // '1.1' is LDAP's "no attributes"; the DN comes back regardless
            sizeLimit: 1
        });

        if (searchEntries.length !== 1) return false;
        userDn = searchEntries[0].dn;   // the DN comes from the directory, not from input
    } finally {
        await client.unbind();
    }

    // Step 3: the password is checked by a bind, never by a filter
    const userClient = new Client({ url: LDAP_URL, timeout: 5000 });
    try {
        await userClient.bind(userDn, password);
        return true;
    } catch {
        return false;   // InvalidCredentialsError and anything else means "not authenticated"
    } finally {
        await userClient.unbind();
    }
}

Why this works: escapeFilter is a tagged template shipped with ldapts - you write the filter structure as a literal and only the interpolated values (${username}) pass through Filter.escape(), so a multi-value filter cannot end up with one value escaped and another forgotten. The credential is verified by a bind, which the directory evaluates against its own password storage, so no filter metacharacter can stand in for a correct password. sizeLimit and the searchEntries.length !== 1 check bound what a wildcard search could return, and rejecting an empty password stops the unauthenticated-bind case: ldapts passes the empty string straight into the BindRequest, which is what RFC 4513 calls an unauthenticated bind, and a directory may answer it with success while granting only anonymous access - so bind() resolves and the caller concludes the password was right.

DN Construction with Validation and the DN Builder

// SECURE - validation rejects what the builder does not escape, then the DN class
// escapes each RDN value
const { DN } = require('ldapts');

// eslint-disable-next-line no-control-regex
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/;

function assertUsableRdnValue(name, value) {
    if (typeof value !== 'string' || value.length === 0 || value.length > 255) {
        throw new Error(`Invalid ${name}`);
    }
    // The builder passes control characters through, and NUL in particular is what
    // RFC 4514 requires to be escaped. No legitimate RDN value contains one.
    if (CONTROL_CHARACTERS.test(value)) {
        throw new Error(`Invalid ${name}`);
    }
}

function buildUserDn(userId, orgUnit) {
    assertUsableRdnValue('user ID', userId);
    assertUsableRdnValue('org unit', orgUnit);

    return new DN()
        .addPairRDN('uid', userId)
        .addPairRDN('ou', orgUnit)
        .addPairRDN('dc', 'example')
        .addPairRDN('dc', 'com')
        .toString();
}

// buildUserDn('svc-backup,ou=service-accounts', 'users')
//   -> uid=svc-backup\,ou\=service-accounts,ou=users,dc=example,dc=com
// The comma and equals sign are escaped, so they stay part of the uid value instead of
// adding an RDN, and the DN still addresses ou=users.

Why this works: The builder escapes each value as an RDN rather than pasting it into a string, so an injected , or = becomes part of the value instead of DN syntax. It backslash-escapes ,, +, ", \, <, >, ;, = and a leading #, and wraps a value with a leading or trailing space in double quotes - the RFC 2253 quoted form, which directory servers accept, rather than RFC 4514's \20. It does not escape control characters, and a NUL is exactly what RFC 4514 requires to be escaped, which is why the guard runs first rather than being left to the caller: the two together are the secure pattern, not the builder alone. Where the value has a known shape - a username, an OU name - validate against that format instead of only excluding control characters. Prefer searching by attribute and using the DN the directory returns; reach for the builder when the base DN is fixed configuration and only a validated RDN value comes from input.

Express Endpoint with Validation, Escaping, and Result Limits

// SECURE - Express REST API
const express = require('express');
const { Client, escapeFilter } = require('ldapts');
const { query, validationResult } = require('express-validator');

const app = express();

app.get('/api/users/search',
    query('q').isLength({ min: 1, max: 50 }).matches(/^[a-zA-Z0-9\s]+$/),
    async (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

        const client = new Client({ url: process.env.LDAP_URL, timeout: 5000 });

        try {
            await client.bind(process.env.LDAP_BIND_DN, process.env.LDAP_PASSWORD);

            // The wildcards are part of the literal; only req.query.q is escaped
            const { searchEntries } = await client.search(process.env.LDAP_BASE_DN, {
                filter: escapeFilter`(cn=*${req.query.q}*)`,
                scope: 'sub',
                attributes: ['cn', 'mail'],
                sizeLimit: 100 // caps result-flooding
            });

            res.json({ users: searchEntries.map((e) => ({ name: e.cn, email: e.mail })) });
        } catch (err) {
            req.log?.error(err);  // detail to the server log
            res.status(500).json({ error: 'Search failed' });  // generic response
        } finally {
            await client.unbind();
        }
    }
);

Why this works: express-validator rejects malformed queries before they reach LDAP; escapeFilter neutralizes any special characters in the validated value that survive, while leaving the * wildcards that belong to the filter literal intact; sizeLimit bounds how many entries a single search can enumerate even for a legitimate wildcard search; and the error handler returns a generic message while the detail goes to the server log, so a failure does not leak directory structure to the caller.

Testing

  • * as the username and any password - the payload that works against a single-term filter. Confirm the login fails, and that the search returns at most one entry rather than the first account in the directory
  • *)(uid=*))(|(uid=* - the payload most write-ups quote, worth running only against the fixed code. Interpolated into (uid=) it yields (uid=*)(uid=*))(|(uid=*), which is two top-level filters rather than one, and ldapts rejects it in its own parser (Unbalanced parens in filter string) before anything is sent - so the unescaped run throws instead of leaking, and the test cannot tell a working fix from a broken filter. Through escapeFilter it becomes the literal assertion value \2a\29\28uid=\2a\29\29\28|\28uid=\2a and matches nothing
  • svc-backup,ou=service-accounts as a user-ID value - confirm the comma is escaped into the RDN value and the lookup stays in the OU the code built the DN for
  • An empty password with a valid username - confirm authentication fails rather than succeeding through an unauthenticated bind
  • A value containing a NUL byte destined for a DN - confirm buildUserDn() throws, since the DN builder itself passes control characters through
  • A legitimate username and search term - confirm normal lookups still succeed after adding validation

Common Pitfalls

  • Using escapeFilter or Filter.escape() on a value that ends up in a DN - RFC 4515 and RFC 4514 define different special-character sets, so filter escaping leaves a DN value's ,, + and = untouched. Build DNs with the DN class instead.
  • Calling escapeFilter as a plain function on an already-built string (escapeFilter(rawFilterString)) instead of using it as a tagged template literal - a tag function receives the strings array that template-literal syntax provides, so a bare string yields nothing but its first character. Measured on ldapts 9.2.0, escapeFilter('ali*ce)(x') returns 'a'. The value is silently truncated rather than escaped, so the mistake breaks legitimate lookups as well as leaving the injection open - it does not fail loudly anywhere.
  • Reaching for a generic string-escaping utility built for HTML output (for example, a library's escape() meant for XSS contexts) - HTML escaping doesn't touch LDAP's special characters (*, (, ), \), so the filter injection is unaffected even though the value "looks" sanitized.
  • Escaping the filter correctly but still comparing the password inside it - the injection is closed and the authentication is still decided by a search result rather than a bind.

Dependencies and Installation

npm install ldapts

escapeFilter, Filter.escape() and DN are part of ldapts itself, so no separate escaping package is needed. ldapts 9.x requires Node.js 22 or later; the 8.x line supports Node.js 20.

Additional Resources