---
key: "nosql-ldap-xpath-injection"
title: "Prevent NoSQL, LDAP, XPath and expression-language injection"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/nosql-ldap-xpath-injection/"
api: "https://softwaresecurity.com/api/v1/articles/nosql-ldap-xpath-injection.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-943","CWE-90","CWE-643","CWE-917","CWE-1287","A05:2025","V1.2","V1.3","V15.3","CAPEC-676","CAPEC-136","CAPEC-83"]
tags: ["Injection","Input validation and parsing","Java"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Prevent NoSQL, LDAP, XPath and expression-language injection

SQL is not the only query language that receives user input. Stop MongoDB operator injection with type checks and $eq, escape LDAP filter values, pass XPath values as variables, and never evaluate SpEL or OGNL built from request data.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/nosql-ldap-xpath-injection/

## In short

Give each query language its own boundary. In MongoDB, attackers send an object such as `{"$ne": null}` where you expected a string, so check the type and compare with `$eq`. Escape LDAP filter values with your library's RFC 4515 function. Pass XPath values as variables instead of quoting them into the expression. Never evaluate SpEL, OGNL or other expression-language strings that contain request data; store user rules as data and evaluate them in code.

- Do check that every MongoDB filter value is the scalar type you expect; parsed JSON can carry `{"$ne": null}`.
- Do compare with `{$eq: value}` or enable Mongoose `sanitizeFilter` as a second layer, and disable server-side JavaScript you don't use.
- Do escape LDAP filter values with `filter_format`, `escapeFilter` or JNDI `{0}` arguments, and reject empty passwords before a bind.
- Do pass XPath values as variables such as `$sku`; never splice quotes into the expression.
- Don't evaluate user-controlled SpEL, OGNL or EL; Spring says `StandardEvaluationContext` must never see untrusted expressions.
- Don't rely on middleware that strips `$` keys; validate types and allowlist field names in the handler.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| MongoDB `security.javascriptEnabled` | `false` (default is `true`) | Same as `--noscripting`. Disables `$where`, `$function`, `$accumulator` and `mapReduce`; verified on MongoDB 8.2.6 and 9.0.2. | https://www.mongodb.com/docs/manual/core/server-side-javascript/ |
| Mongoose `sanitizeFilter` | `true` (default is `false`) | Wraps nested objects with `$`-prefixed keys in `$eq` and refuses `$where`; `mongoose.trusted()` exempts a known selector. | https://mongoosejs.com/docs/api/mongoose.html#Mongoose.prototype.set() |
| Express 5 query parser | `"simple"` (the Express 5 default) | `"extended"` (`qs`) turns `code[$ne]=x` into a nested object. Repeated keys become arrays in both modes. | https://expressjs.com/en/guide/migrating-5.html |
| `express.urlencoded()` `extended` | `false` (the Express 5 default) | `true` turns `code[$ne]=x` into `{code: {$ne: "x"}}`. | https://expressjs.com/en/guide/migrating-5.html |
| LDAP simple bind with an empty password | Reject in the application before binding | RFC 4513 section 5.1.2 unauthenticated bind. OpenLDAP 2.6.8 refused it with `unwillingToPerform`, but the RFC only says servers SHOULD. | https://www.rfc-editor.org/rfc/rfc4513.html#section-5.1.2 |
| SpEL evaluation context for non-constant expressions | `SimpleEvaluationContext.forReadOnlyDataBinding()` | Spring describes its limits as best effort; `StandardEvaluationContext` must never evaluate an untrusted expression. | https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html |
| SpEL maximum expression length | 10,000 characters (default) | Set through `SpelParserConfiguration` or `spring.context.expression.maxLength`. | https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html |
| Struts `struts.ognl.expressionMaxLength` | 256 (documented default); keep it small | Also enable `struts.allowlist.enable`, and never use request values in forced `%{...}` evaluation. | https://struts.apache.org/security/ |

## Verify it

1. POST `{"code": {"$ne": null}}`, `{"code": {"$regex": "^A"}}` and `{"code": ["x"]}` to every JSON endpoint that queries MongoDB; expect 400, never a match.
2. Run a `$where` query against each environment; with server-side JavaScript disabled it fails with `no globalScriptEngine in $where parsing`.
3. Send `*`, `a*` and `alice)(uid=*` to LDAP-backed lookups and logins; expect no match, and confirm an empty password returns false without a bind.
4. Send `x' or '1'='1` to XPath-backed lookups and confirm every XPath expression is a constant in code.
5. Search for `parseExpression`, `StandardEvaluationContext` and OGNL evaluation of request data, and run CodeQL or Semgrep rules for NoSQL, LDAP, XPath and expression injection.

## What you will learn

- Explain why MongoDB operator injection depends on value types rather than quotes.
- Use the right escaping or binding API for LDAP filters and XPath.
- Replace evaluation of user-supplied expressions with rules stored as data.
- Write regression tests that run against the real query engine.

Audience: Backend developers and AppSec reviewers who work with MongoDB, directory services, XML data or Java expression languages.

Before you start:

- Read request handlers in JavaScript, Python or Java.
- Know how parameterized SQL queries separate data from syntax.

Time: About 36 minutes: 11 to read and 25 for the exercise

## Each query language needs its own boundary

Injection happens when input crosses into a language that another component interprets. SQL gets most of the attention, but the same failure shows up in MongoDB query documents, LDAP search filters, XPath expressions and expression languages such as SpEL and OGNL. Each has its own special syntax, so each needs its own fix. A generic sanitizer that strips a few characters cannot cover them all.

The table maps each grammar to the input an attacker sends, the primary fix and a backstop. Fixing the grammar does not settle authorization. A query that can no longer be rewritten can still return a record the caller should not see, so keep tenant and owner conditions in the query.

| Grammar | Attacker input | Primary fix | Backstop |
| --- | --- | --- | --- |
| MongoDB query document | `{"$ne": null}`, `{"$regex": "^a"}`, a `$where` string | Require scalar types and build the filter from named fields | `$eq`, Mongoose `sanitizeFilter`, `javascriptEnabled: false` |
| LDAP search filter | `*`, `)(`, `alice)(attr=x*` | RFC 4515 escaping through the library | Least-privilege bind account, size limit |
| LDAP distinguished name | `, + = \ " < > ;` | RFC 4514 DN escaping, or the DN the server returned | Search-then-bind |
| XPath 1.0 | `x' or '1'='1` | XPath variables such as `$sku` | Expressions kept as constants |
| SpEL, OGNL, EL | `T(java.lang.Runtime)...`, `%{...}` | Do not evaluate request data; store rules as data | `SimpleEvaluationContext`, length limits, framework allowlists |

## MongoDB: the attack is the value's type, not a quote

MongoDB filters are documents, not strings, so quotes are not the issue. The issue is that parsed JSON can put an object where the code expects a string. If a handler runs `findOne({ code: req.body.code })` and the body is `{"code": {"$ne": null}}`, the driver sends a query operator, and the filter now means "any code that is not null". `{"$gt": ""}` does the same. `{"$regex": "^K"}` returns a yes or no answer, which lets an attacker recover a secret one character at a time.

Which inputs can carry objects depends on the parser. `express.json()` always can. In Express 5 the default query parser (`"simple"`) and `express.urlencoded()` with its new default of `extended: false` do not nest keys, so `code[$ne]=x` arrives as a key literally named `code[$ne]`. Switching either one to the `qs`-based extended mode turns it back into `{code: {$ne: "x"}}`. Repeated keys such as `?code=a&code=b` produce an array in every mode. Flask's `request.get_json()` and other APIs that return the raw parsed body have the same exposure.

The route below redeems a single-use invite code. Against MongoDB 8.2.6 and 9.0.2, `{"$ne": null}` redeemed an invite without knowing any code, and `{"$regex": "^Z"}` returned 200 for the first letter of a real code and 404 for other letters.

```js
// invites-vulnerable.mjs: Express 5 and the MongoDB Node.js driver
import express from 'express';

export function mountRedeem(app, invites) {
  app.post('/invites/redeem', express.json(), async (req, res) => {
    // req.body.code can be any JSON value, including {"$ne": null}
    const invite = await invites.findOneAndUpdate(
      { code: req.body.code, used: false },
      { $set: { used: true } },
    );
    if (!invite) return res.status(404).json({ error: 'unknown code' });
    res.json({ team: invite.team });
  });
}

```

## Fix: require a string, then compare with $eq

Check the type and shape the feature needs before the value reaches the query. Here that means a string that matches the invite format. For larger bodies, a schema validator such as Ajv, Zod or Pydantic does the same job. A Pydantic 2.14 model with `code: str` rejected an operator object, an integer and a list.

Then make the comparison literal. `{ code: { $eq: code } }` matches only documents whose code equals the value. If a later edit removes the type check, an object such as `{"$ne": null}` is compared as a literal document and matches nothing; both server versions returned no documents for it. Mongoose does this wrapping for you after `mongoose.set('sanitizeFilter', true)`. In Mongoose 9.11 that setting turned the same payloads into cast errors and refused `$where` filters.

Build filters from named fields. Do not pass `req.body` or `req.query` to `find()`. If users choose the field or the operator, map an allowlisted name such as `status` to a fixed field, and an allowlisted word such as `before` to `$lt`. A user-supplied key can be `$where` or `$expr`, which turns data into an operator at the top level of the filter.

```js
// invites-fixed.mjs
import express from 'express';

const CODE = /^[A-Z0-9]{4}-[A-Z]{3,8}$/;

export function mountRedeem(app, invites) {
  app.post('/invites/redeem', express.json({ limit: '1kb' }), async (req, res) => {
    const code = req.body?.code;
    // 1. Accept only the type and shape this feature needs.
    if (typeof code !== 'string' || !CODE.test(code)) {
      return res.status(400).json({ error: 'invalid code' });
    }
    // 2. $eq keeps the value literal even if a later edit drops step 1.
    const invite = await invites.findOneAndUpdate(
      { code: { $eq: code }, used: false },
      { $set: { used: true } },
    );
    if (!invite) return res.status(404).json({ error: 'unknown code' });
    res.json({ team: invite.team });
  });
}

```

## Turn off JavaScript inside the database

`$where`, `$function`, `$accumulator` and `mapReduce` run JavaScript inside `mongod`. A `$where` string built from input, such as `"this.owner == '" + owner + "'"`, adds classic string injection to the operator problem. The payload `sleep(300) || true` held a query for at least 300 ms, so a few requests can tie up the server.

MongoDB recommends `$expr` with ordinary aggregation operators instead of `$where`, because `$expr` does not run JavaScript. If the application does not use these operators, turn server-side scripting off. With `javascriptEnabled: false`, MongoDB 9.0.2 rejected both `$where` and `$function`. Treat client-supplied aggregation pipelines the same way: `$lookup` can read other collections, and `$out` and `$merge` write to them.

```yaml
# mongod.conf
security:
  javascriptEnabled: false   # same effect as the --noscripting flag

```

## LDAP: escape filter values with the library function

An LDAP search filter is a string such as `(&(objectClass=inetOrgPerson)(uid=alice))`. Splicing a username into that string hands the filter syntax to the user. Against OpenLDAP 2.6.8, the input `*` returned every person. The input `alice)(departmentNumber=e*` added a second condition, so the result revealed whether Alice's department starts with e. Repeating the question reveals the whole value.

RFC 4515 requires five characters in a filter value to be escaped as a backslash and two hex digits: `*` as `\2a`, `(` as `\28`, `)` as `\29`, `\` as `\5c` and NUL as `\00`. Use the library's function rather than your own replace calls: `filter_format()` or `escape_filter_chars()` in python-ldap, the `escapeFilter` tagged template in ldapts 8.2 and later (filter objects such as `EqualityFilter` in older releases), and `{0}` placeholders with a `filterArgs` array in Java's `DirContext.search()`, which escapes each argument. Distinguished names follow different rules (RFC 4514), so a filter escaper does not protect a DN. The simplest way to avoid building DNs is to search for the entry and use the DN the server returns.

```python
# directory.py: python-ldap 3.4 search-then-bind
import ldap
import ldap.filter

PEOPLE = "ou=people,dc=example,dc=com"


def find_person(conn, uid):
    """Return at most one entry; uid can never change the filter."""
    if not isinstance(uid, str) or not 1 <= len(uid) <= 64:
        raise ValueError("invalid uid")
    flt = ldap.filter.filter_format(
        "(&(objectClass=inetOrgPerson)(uid=%s))", [uid])
    return conn.search_ext_s(PEOPLE, ldap.SCOPE_ONELEVEL, flt,
                             ["cn", "mail"], sizelimit=2)


def authenticate(uri, service_conn, uid, password):
    if not isinstance(password, str) or password == "":
        return False  # an empty password would be an unauthenticated bind
    entries = find_person(service_conn, uid)
    if len(entries) != 1:
        return False
    user_conn = ldap.initialize(uri)
    try:
        user_conn.simple_bind_s(entries[0][0], password)  # DN from the server
        return True
    except ldap.INVALID_CREDENTIALS:
        return False
    finally:
        user_conn.unbind_s()

```

## Authenticate with search-then-bind and refuse empty passwords

Do not check passwords inside a filter such as `(userPassword=...)`. Search for the entry with an escaped filter, require exactly one match, and then bind as that entry's DN with the supplied password. A size limit of two stops a filter that unexpectedly matches many entries.

Reject an empty password before calling bind. RFC 4513 section 5.1.2 calls a simple bind with a name and an empty password an unauthenticated bind: it grants anonymous access and proves nothing about the user. The RFC says servers should refuse it by default, and OpenLDAP 2.6.8 did, with `unwillingToPerform`. That is a recommendation, not a guarantee, so the application has to check. Give the lookup account read access only to the attributes the feature needs.

The Node.js version below uses ldapts and passed equivalent tests against the same OpenLDAP server.

```js
// directory.mjs: ldapts 8.2+ search-then-bind
import { Client, InvalidCredentialsError, escapeFilter } from 'ldapts';

export async function authenticate(url, service, uid, password) {
  if (typeof uid !== 'string' || typeof password !== 'string' || password === '') {
    return false;
  }
  const svc = new Client({ url });
  let dn;
  try {
    await svc.bind(service.dn, service.password);
    const { searchEntries } = await svc.search('ou=people,dc=example,dc=com', {
      scope: 'one',
      filter: escapeFilter`(&(objectClass=inetOrgPerson)(uid=${uid}))`,
      attributes: ['cn'],
      sizeLimit: 2,
    });
    if (searchEntries.length !== 1) return false;
    dn = searchEntries[0].dn;
  } finally {
    await svc.unbind();
  }
  const user = new Client({ url });
  try {
    await user.bind(dn, password);
    return true;
  } catch (err) {
    if (err instanceof InvalidCredentialsError) return false;
    throw err;
  } finally {
    await user.unbind();
  }
}

```

## XPath: pass values as variables

XPath 1.0 has no escape sequence for a quote inside a string literal. The only workaround is to split the value with `concat()`, which is easy to get wrong, so do not build an expression such as `//product[@sku='...']` from input. The input `x' or '1'='1` turns the predicate into `(@visibility='public' and @sku='x') or '1'='1'`, which is true for every product, including internal ones. With `substring()`, the same trick becomes a yes or no oracle for hidden values.

Most XPath libraries accept variables. Write `$sku` in the expression and pass the value separately: lxml takes keyword arguments on `xpath()` or on a compiled `etree.XPath`, the npm `xpath` package takes a `variables` object in `parse(expression).select({ node, variables })`, and Java's `javax.xml.xpath` accepts an `XPathVariableResolver`. The value is then a string, never syntax. The test below confirms that a quote is ordinary data and that the internal product stays hidden.

```python
# catalog.py: lxml 6, XPath 1.0
from lxml import etree

CATALOG = etree.fromstring(b"""<catalog>
  <product sku="A-100" visibility="public"><name>Desk lamp</name></product>
  <product sku="B-200" visibility="internal"><name>Prototype charger</name></product>
</catalog>""")


def find_vulnerable(sku):  # do not use: input becomes XPath source
    return CATALOG.xpath(f"//product[@visibility='public' and @sku='{sku}']")


FIND = etree.XPath("//product[@visibility='public' and @sku=$sku]")


def find(sku):
    return FIND(CATALOG, sku=sku)  # sku is bound as a string variable


probe = "x' or '1'='1"
assert [p.get("sku") for p in find_vulnerable(probe)] == ["A-100", "B-200"]
assert find(probe) == []
assert [p.findtext("name") for p in find("A-100")] == ["Desk lamp"]
assert find("B-200") == []      # internal items stay hidden
assert find("O'Brien") == []    # a quote is ordinary data
print("PASS")

```

## Expression languages: never evaluate request data

SpEL, OGNL and Jakarta EL can read properties, call methods and create objects. When request data becomes part of an expression, the attacker gets the same powers and can reach the runtime. Spring Cloud Function evaluated a SpEL routing expression supplied by the caller (CVE-2022-22963), and Confluence Server and Data Center had OGNL injection flaws that let unauthenticated attackers run code (CVE-2021-26084 and CVE-2022-26134). All three are in CISA's Known Exploited Vulnerabilities catalog, and NVD maps each to CWE-917.

The fix is to stop evaluating input. If users need custom rules such as "total over 100 and country is NL", store each rule as data, with a field and an operator from allowlists and a typed value, and evaluate it in ordinary code. If you inherit a feature that must evaluate SpEL, use `SimpleEvaluationContext.forReadOnlyDataBinding()`. It rejects type references and constructors, and it rejects method calls unless you enable them. Spring calls these limits best effort and says `StandardEvaluationContext` must never evaluate an expression from an untrusted source. SpEL also refuses expressions longer than 10,000 characters by default. Template engines, `eval` and the limits of in-process sandboxes are covered in /domains/appsec/secure-coding/code-template-injection/.

For Struts, Apache's guidance is to never pass request values to forced `%{...}` evaluation, to keep `struts.ognl.expressionMaxLength` small (its documented default is 256), and to enable the OGNL allowlist with `struts.allowlist.enable`. The Confluence flaws above were product bugs rather than customer code, so patching is part of the control.

```java
// SpelRules.java: spring-expression 7.0
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class SpelRules {
    public record Order(String country, int total) {}

    static final SpelExpressionParser PARSER = new SpelExpressionParser();

    // Vulnerable: full SpEL, including T(...) type references and constructors
    static Object evaluateUnsafe(String rule, Order order) {
        return PARSER.parseExpression(rule).getValue(new StandardEvaluationContext(order));
    }

    // Restricted: read-only properties; no types, constructors or method calls
    static Boolean evaluateRestricted(String rule, Order order) {
        EvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
        return PARSER.parseExpression(rule).getValue(ctx, order, Boolean.class);
    }

    public static void main(String[] args) {
        Order order = new Order("NL", 120);
        String probe = "T(java.lang.System).getProperty('user.home') != null";
        System.out.println(evaluateUnsafe(probe, order));           // true: a static method ran
        System.out.println(evaluateRestricted("total > 100 and country == 'NL'", order)); // true
        try {
            evaluateRestricted(probe, order);
        } catch (SpelEvaluationException e) {
            System.out.println(e.getMessageCode());                 // TYPE_NOT_FOUND
        }
    }
}

```

## Pitfalls that reopen the hole

Most regressions come from a control that looks complete but sits in the wrong place.

- Key-stripping middleware. Removing keys that start with `$` is a denylist, and it can disappear in an upgrade. `express-mongo-sanitize` 2.2.0 throws on every request under Express 5 because `req.query` is now a getter. A team that removes it to get the app running loses its only check unless the handlers validate types.
- Coercing instead of rejecting. `String(value)` turns an object into `"[object Object]"` and hides the attempt. Return 400 so the probe shows up in logs.
- Using the wrong escaper. A filter escaper does not protect a DN, HTML encoding does not protect XPath, and SQL escaping protects none of these.
- Passing search text to `$regex`. Escape regular-expression metacharacters (ASVS 5.0 requirement 1.2.9) so the text matches literally, or offer prefix search through a fixed operator.
- Second-order values. A value stored safely can later be used to build a filter or an expression. Apply the same controls where the stored value is used.
- Verbose errors. Returning a driver or parser error helps an attacker tune a payload. Return a generic 400 and log the detail.

## Verify against the real engines

Test against the real interpreter. `mongodb-memory-server` starts a real `mongod` inside a unit test, an OpenLDAP container starts in seconds, and lxml and Spring's parser run in process. Assert both sides: the legitimate value still works, and each payload is rejected or matches nothing. The table lists results from the tested snippets in this guide.

In code review, search for the sinks directly: `find`, `findOne`, `aggregate` and `update` calls that receive `req.body` or `req.query`; `$where` and `$function`; LDAP search calls whose filter is built with `+`, f-strings or `format()`; `xpath()` or `evaluate()` calls on built strings; and `parseExpression` or OGNL evaluation that touches request data.

Static analysis finds string-built queries quickly. CodeQL covers these sinks with `js/sql-injection` (which includes MongoDB queries), `py/nosql-injection`, `py/ldap-injection`, `java/ldap-injection`, `js/xpath-injection`, `py/xpath-injection`, `java/spel-expression-injection` and `java/ognl-injection`. For manual and dynamic testing, the OWASP Web Security Testing Guide has test cases for NoSQL, LDAP and XPath injection. The fix cards at /fix/nosql-injection/ and /fix/ldap-injection/ have tested versions of the type check and the filter escaping.

```js
// redeem.test.mjs: run with `node --test` (mongodb, mongodb-memory-server, express)
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { MongoClient } from 'mongodb';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { mountRedeem } from './invites-fixed.mjs';

test('invite codes are matched as literal strings', async (t) => {
  const mongod = await MongoMemoryServer.create();
  const client = await MongoClient.connect(mongod.getUri());
  t.after(async () => { await client.close(); await mongod.stop(); });
  const invites = client.db('test').collection('invites');
  await invites.insertOne({ code: 'K7Q2-PLUM', team: 'red', used: false });

  const app = express();
  mountRedeem(app, invites);
  const server = await new Promise((ok) => { const s = app.listen(0, () => ok(s)); });
  t.after(() => server.close());
  const redeem = (code) => fetch(`http://127.0.0.1:${server.address().port}/invites/redeem`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ code }),
  }).then((r) => r.status);

  assert.equal(await redeem({ $ne: null }), 400);      // operator object
  assert.equal(await redeem({ $regex: '^K' }), 400);   // blind extraction probe
  assert.equal(await redeem(['K7Q2-PLUM']), 400);      // array instead of string
  assert.equal(await redeem('K7Q2-PLUM'), 200);        // the real code works once
  assert.equal(await redeem('K7Q2-PLUM'), 404);        // and only once
});

```

| Input | Vulnerable code | Fixed code |
| --- | --- | --- |
| `code: {"$ne": null}` | 200, invite redeemed | 400 |
| `code: {"$regex": "^Z"}` | 200 or 404 depending on the prefix | 400 |
| `uid: *` | Every person | No entries |
| `uid: alice)(departmentNumber=e*` | Match only if the department starts with `e` | No entries |
| password: empty string | Depends on the server's unauthenticated-bind setting | `false`, no bind attempted |
| `sku: x' or '1'='1` | Every product, including internal ones | No products |
| `rule: T(java.lang.System).getProperty(...)` | The static method runs | `SpelEvaluationException`, `TYPE_NOT_FOUND` |

## Guided practice

Review a support portal that searches tickets in MongoDB, checks staff logins against LDAP, reads an XML price list and sorts results with SpEL. Decide what each input does before and after the fix.

### Supplied material

```text
Route A (Express 5, express.json()):
  tickets.find({ customerEmail: req.body.email, status: req.body.status })
Route B (python-ldap):
  filter = "(&(objectClass=inetOrgPerson)(uid=" + uid + "))"; then bind as the entry's DN
Route C (lxml):
  doc.xpath("//price[@region='" + region + "']")
Route D (Spring):
  parser.parseExpression(request.getParameter("sort")).getValue(new StandardEvaluationContext(ticket))
Inputs:
  A1 {"email": "ann@example.com", "status": "open"}
  A2 {"email": {"$ne": null}, "status": "open"}
  A3 {"email": "ann@example.com", "status": {"$in": ["open", "closed"]}}
  B1 uid "ann", password "correct-horse"
  B2 uid "*", password "anything"
  B3 uid "ann", password ""
  C1 region "eu"
  C2 region "eu' or '1'='1"
  D1 sort "priority"
  D2 sort "T(java.lang.Runtime).getRuntime().availableProcessors()"
```

### 1. Predict Route A

State which tickets A2 and A3 return with the current code, and why A3 is still a problem.

Worked answer: A2 returns open tickets for every customer, because `{$ne: null}` matches any email. A3 widens the status filter to closed tickets; even if that looks harmless, the caller has chosen the operator. Both must be rejected because email and status must be strings.

### 2. Repair Route A

Write the validation and the filter for the fixed route.

Worked answer: Require `typeof email === 'string'` with an email pattern and `status` in an allowlist such as `open`, `pending` and `closed`. Query `{ customerEmail: { $eq: email }, status: { $eq: status } }` and add the caller's customer ID from the session, because another customer's valid email is an authorization problem, not an injection.

### 3. Repair Route B

Rewrite the filter with python-ldap and decide B1, B2 and B3.

Worked answer: Use `ldap.filter.filter_format` with `[uid]`. B1 binds as Ann's DN and succeeds. B2 becomes `uid=\2a`, matches nobody and fails. B3 is refused before any bind because the password is empty.

### 4. Repair Route C

Rewrite the XPath call and evaluate C1 and C2.

Worked answer: `doc.xpath("//price[@region=$region]", region=region)`. C1 returns the EU prices. C2 is a literal string that matches no price, and the expression text never changes.

### 5. Remove Route D's interpreter

Replace the SpEL call with a design that keeps D1 working.

Worked answer: Map allowed sort keys to comparators in code, for example `priority` and `created`. D1 works; D2 is not a key and gets a 400. SimpleEvaluationContext would also reject D2 with `TYPE_NOT_FOUND`, but a map removes the interpreter entirely.

Deliverable: A review note listing each route, the input that proves the bug, the repaired code, and one regression test per route that asserts a legitimate input and a payload.

Scope: The fixture is complete, so this works on paper. To run it, adapt the tested snippets in this guide, which use `mongodb-memory-server`, an OpenLDAP test container, lxml and `spring-expression`.

## Executable practice

- [SSTI lab: Render a welcome banner without template injection](https://softwaresecurity.com/labs/template-injection-jinja/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Cheat Sheet Series: NoSQL Security](https://cheatsheetseries.owasp.org/cheatsheets/NoSQL_Security_Cheat_Sheet.html)
- [OWASP Cheat Sheet Series: LDAP Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.html)
- [RFC 4515, section 3: escaping in LDAP search filter strings](https://www.rfc-editor.org/rfc/rfc4515.html#section-3). June 2006.
- [RFC 4513, section 5.1.2: unauthenticated simple bind](https://www.rfc-editor.org/rfc/rfc4513.html#section-5.1.2). June 2006.
- [MongoDB Manual: server-side JavaScript and how to disable it](https://www.mongodb.com/docs/manual/core/server-side-javascript/)
- [Mongoose API: the sanitizeFilter option](https://mongoosejs.com/docs/api/mongoose.html#Mongoose.prototype.set()). Mongoose 9.11.1.
- [Express: migrating to Express 5 (query parser and urlencoded defaults)](https://expressjs.com/en/guide/migrating-5/)
- [lxml: XPath variables](https://lxml.de/xpathxslt.html)
- [Spring Framework reference: SpEL evaluation contexts](https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html)
- [Apache Struts: security guide (OGNL)](https://struts.apache.org/security/)