Back to Blog
critical SEVERITY8 min read

LDAP Filter Injection in da_unique_email_validator Fixed

The registration-time email uniqueness validator, `da_unique_email_validator`, formatted the submitted email address straight into an LDAP search filter with Python's `%` operator, so filter metacharacters in the email were interpreted as filter syntax. The fix wraps the value in `ldap.filter.escape_filter_chars()` (and imports the `ldap.filter` submodule explicitly), so a submitted address is always treated as a literal attribute value. Any deployment with `ldap login` enabled and a bind accoun

O
By Orbis AppSec
Published September 13, 2026Reviewed September 13, 2026

Answer Summary

The affected code is the first-party registration-form validator `da_unique_email_validator` in a Python (Flask/WTForms) web application; no published package version range applies, and the issue is present in any build where the `ldap login` configuration block is enabled with a `base dn`, `bind email`, and `bind password`. An unauthenticated attacker could submit an email such as `*)(uid=*))(|(uid=*` in the registration field and have it become live LDAP filter syntax, turning the uniqueness check into a boolean oracle for enumerating directory entries and attribute values, and allowing the duplicate-email check itself to be steered. The fix passes the submitted address through `ldap.filter.escape_filter_chars()` before it is interpolated into the configured `search pattern`, and adds the required `import ldap.filter`; no released "fixed in" version identifier was assigned. No CVE, GHSA, or CWE identifier was assigned to this finding.

Vulnerability at a Glance

cweN/A
fixInterpolate `ldap.filter.escape_filter_chars(form.email.data)` instead of the raw value, and import `ldap.filter`
riskUnauthenticated directory enumeration and blind attribute extraction via the registration form's uniqueness check
languagePython
root cause`search pattern` (default `mail=%s`) was populated with `form.email.data` using `%` string formatting, with no RFC 4515 escaping
vulnerabilityLDAP filter injection through an unescaped registration email address

Summary

The registration-time email uniqueness validator, da_unique_email_validator, formatted the submitted email address straight into an LDAP search filter with Python's % operator, so filter metacharacters in the email were interpreted as filter syntax. The fix wraps the value in ldap.filter.escape_filter_chars() (and imports the ldap.filter submodule explicitly), so a submitted address is always treated as a literal attribute value. Any deployment with ldap login enabled and a bind account configured was reachable from an unauthenticated registration form.

Introduction

Registration forms are the least-authenticated code path in a web application, and this one reached all the way into a corporate directory. When ldap login is enabled, the validator that answers "is this email already taken?" does not just check the local user table — it opens an LDAP connection, binds with the configured service account, and runs a search built like this:

search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)

form.email.data is whatever the visitor typed into the email field. The % operator does not know or care that the result is about to be parsed as an RFC 4515 search filter, so every (, ), *, and \ in that string becomes filter syntax rather than filter data. The validator then hands the string to a search_s() call on a connection bound with privileged credentials, and turns the presence or absence of results into a visible form validation message.

That combination — attacker-controlled filter grammar, privileged bind, and an observable boolean answer — is the textbook setup for blind LDAP injection. If you maintain code that builds LDAP filters, DNs, or any other query grammar with %, +, or f-strings, this is the shape of bug to look for.

Affected Versions

Affected not applicable (first-party code) — any build where the ldap login configuration block is enabled with base dn, bind email, and bind password set
Fixed in not applicable (first-party code) — fixed by the security pull request that adds escape_filter_chars() to the validator
Ecosystem not applicable (first-party Python / Flask-WTForms code)
CVE / GHSA not assigned
CWE unknown (no CWE was assigned to this finding; the class is LDAP filter injection)

The Vulnerability Explained

The vulnerable code

The entire flaw fits on one line inside da_unique_email_validator:

if daconfig['ldap login'].get('enable', False) and ...:
    ldap_server = daconfig['ldap login'].get('server', 'localhost').strip()
    base_dn = daconfig['ldap login']['base dn'].strip()
    search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)
    connect = ldap.initialize('ldap://' + ldap_server)
    connect.simple_bind_s(daconfig['ldap login']['bind email'],
                          daconfig['ldap login']['bind password'])

Two properties make this dangerous rather than merely sloppy:

  1. The pattern is a filter template, not a parameterized query. There is no placeholder API in python-ldap that escapes values for you. "mail=%s" % value produces a raw filter string; the LDAP server receives exactly what the string says.
  2. The bind is privileged. The search runs as the configured bind email service account, not as the anonymous visitor. Whatever the injected filter can see, the attacker can infer.

Exploiting it

Take the default pattern mail=%s and submit this as the registration email:

*)(uid=*))(|(uid=*

The interpolation yields:

mail=*)(uid=*))(|(uid=*

The mail= comparison is now closed off early and new filter components are grafted on. Variations of this payload let an attacker:

  • Turn the check into a tautology. A payload reducing to mail=* matches every entry that has a mail attribute. The uniqueness check now reports a collision for every address a legitimate user tries — a denial of registration for the whole instance, achievable by one HTTP POST.
  • Enumerate the directory as a boolean oracle. Because the validator's outcome is rendered back in the form ("already in use" versus accepted), each submission returns one bit. Payloads that append conditions such as )(mail=admin@corp.example or )(cn=Domain Admins let an attacker confirm whether specific accounts, groups, or objectClasses exist behind the bind account's view.
  • Extract attribute values character by character. Wildcard-anchored probes — )(mail=a*, )(mail=ab*, and so on — narrow down real addresses one character at a time. This is classic blind extraction; the only cost is HTTP requests.
  • Probe attributes the web tier never intended to expose. The injected filter is not constrained to mail. Any attribute the bind account can read is fair game as a search condition, including userPassword presence checks, employeeNumber, or custom schema fields, depending on directory ACLs.
  • Push expensive searches at the directory. Unindexed wildcard filters over a large base dn are cheap to send and expensive to answer, giving an unauthenticated visitor a lever on directory CPU.

Note what the attacker does not need: no account, no session, no knowledge of the base dn, and no visibility into the configured search pattern. The default pattern is guessable, and the response of the registration form tells them whether their payload parsed.

Real-world impact

For an organization that wired this application to its central directory, an internet-exposed registration page became a read-side query interface to that directory, running under service-account privileges. That leaks the employee roster and group membership — exactly the reconnaissance material that precedes credential-stuffing and phishing. The secondary effect is availability: a tautological filter makes the "is this email unique?" answer always "no", blocking new signups until the payload is noticed.

The Fix

The change is deliberately small and surgical. First, the ldap.filter submodule is imported explicitly alongside ldap, inside the same try/except ImportError guard that disables ldap login when python-ldap is not installed:

try:
    import ldap
    import ldap.filter
except ImportError:
    if 'ldap login' not in daconfig:
        daconfig['ldap login'] = {}

This matters: import ldap does not reliably bind filter as an attribute of the ldap package, so without this line the call below could raise AttributeError at request time. Keeping it inside the existing try block preserves the graceful degradation path for installs without python-ldap.

Then the interpolation itself is fixed.

Before:

search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (form.email.data,)

After:

search_filter = daconfig['ldap login'].get('search pattern', "mail=%s") % (ldap.filter.escape_filter_chars(form.email.data),)

escape_filter_chars() implements RFC 4515 escaping: \, *, (, ), and NUL are rewritten into their \5c, \2a, \28, \29, and \00 hex forms. The payload *)(uid=*))(|(uid=* therefore reaches the directory as the literal string \2a\29\28uid=\2a\29\29\28|\28uid=\2a — a single, harmless attribute value that will simply match nothing.

Crucially, the escaping is applied to the value, not to the pattern. Deployments that override search pattern with something like (&(objectClass=person)(mail=%s)) keep their custom filter semantics intact and still get a safely-escaped substitution. And because normal email addresses do not contain *, (, ), \, or NUL, legitimate registrations produce byte-identical filters before and after the fix — behavior is preserved for every non-malicious input.

One boundary worth stating: escape_filter_chars() is the correct escaper for search filters only. If user data ever flows into a distinguished name, that requires ldap.dn.escape_dn_chars() instead, since DN syntax escapes a different character set. The fix here is scoped to the filter path, which is the only place the submitted email lands.

Key Takeaways

  • "mail=%s" % form.email.data is not a parameterized query. python-ldap has no bind-parameter API for filters; the only safe substitution is a value passed through ldap.filter.escape_filter_chars() first.
  • A configurable filter template such as search pattern does not make the interpolation safe — escape the value being substituted so that operator-supplied patterns keep working unchanged.
  • import ldap alone is not enough to call ldap.filter.escape_filter_chars(); the submodule needs its own import, and it belongs inside the same ImportError guard that disables the LDAP login feature.
  • A uniqueness validator that reports "this email is already in use" is a boolean oracle. Combined with injectable filter syntax and a privileged bind email account, one bit per request is enough to enumerate a directory.
  • Unauthenticated registration fields deserve the same scrutiny as admin query endpoints, precisely because they reach backend directories before any session exists.

How Orbis AppSec Detected This

  • Source: the email field of the registration form, read as form.email.data inside da_unique_email_validator — unauthenticated, attacker-controlled input.
  • Sink: an LDAP search filter string built by %-formatting the configured search pattern (default mail=%s), then executed over a connection created by ldap.initialize() and bound with the bind email service credentials.
  • Missing control: no RFC 4515 filter escaping. The characters (, ), *, \, and NUL passed through unmodified, so the submitted address could restructure the filter expression instead of acting as a value.
  • CWE: unknown — no CWE identifier was assigned to this finding. The behavior is LDAP filter injection.
  • Fix: the email value is now wrapped in ldap.filter.escape_filter_chars() before interpolation, with import ldap.filter added so the escaper is guaranteed to be available.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

A single % operator turned a registration-form uniqueness check into an unauthenticated, privileged query channel into an organization's LDAP directory. The payload *)(uid=*))(|(uid=* is all it took to escape the mail= comparison in the default search pattern and start asking the directory questions the application never meant to ask — with each answer handed back as a form validation message.

The remedy is one call: ldap.filter.escape_filter_chars(form.email.data), plus the import ldap.filter that makes it callable. No behavior changes for real email addresses, no configuration migration, and no way left for filter metacharacters to be parsed as filter grammar. If your codebase builds LDAP filters from request data anywhere, grep for %, +, and f-strings around search_s() — that is where the next one of these is hiding.

Prevention and further reading

Frequently Asked Questions

Does the fix protect deployments that override the default `mail=%s` search pattern?

Yes. The escaping is applied to the submitted email value before it is substituted, not to the pattern, so any custom `search pattern` that uses `%s` for the address benefits. A custom pattern that inserts other user-controlled data would need its own escaping.

Why was `import ldap.filter` added when `import ldap` was already present?

`ldap.filter` is a submodule and is not guaranteed to be bound as an attribute of `ldap` by `import ldap` alone, so calling `ldap.filter.escape_filter_chars()` could raise `AttributeError`. The explicit submodule import sits inside the same `try`/`except ImportError` block that disables `ldap login` when python-ldap is unavailable.

Am I affected if `ldap login` is not enabled in my configuration?

No. The vulnerable branch of `da_unique_email_validator` only runs when `ldap login` has `enable` set and `base dn`, `bind email`, and `bind password` are all configured; otherwise the LDAP search is never issued.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #989

Related Articles

high

installPlugin(): Unvalidated npm Package Names Reach npm install

A plugin manager service exposed an `installPlugin(plugin: PluginInfo)` method that passed `plugin.packageName` and `plugin.version` straight into the platform's npm install routine with no validation, no blocklist, and no integrity verification of the fetched tarball. Because npm treats a non-semver "version" as a fetch specifier — a tarball URL, a git ref, a local path — an attacker who could influence the plugin listing could get arbitrary code installed and executed with full Electron/Node p

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

critical

eval() in Async Function Constructor Enables Runtime Escape

The eval.mjs command handler used raw `eval()` to execute JavaScript expressions, creating a critical code injection path if owner credentials are compromised. The fix replaces `eval()` with the `AsyncFunction` constructor and explicitly shadows `process`, `require`, and other runtime globals as parameters, preventing evaluated code from reaching the Node.js runtime even when authentication boundaries fail.

high

How Regular Expression Denial of Service (ReDoS) Happens in Node.js trim-newlines and How to Fix It

CVE-2021-33623 exposed a Regular Expression Denial of Service (ReDoS) vulnerability in the npm package `trim-newlines` versions 1.0.0 and earlier. The vulnerable `.end()` method used an inefficient regex pattern that could cause severe performance degradation when processing malicious input. Upgrading to version 4.0.1 patches the regex implementation and eliminates the attack surface.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

critical

ExternalHttpClient::request() Sent Basic Auth Over Plain HTTP

The `ExternalHttpClient::request()` helper accepted a `$basicAuth` string and passed it straight to the HTTP client's `auth` option without checking that the target URL used `https://`. Any external JSON data source configured with an `http://` endpoint therefore shipped a base64-encoded `Authorization: Basic` header in cleartext on every scheduled load. The fix rejects the request outright — before a client is even created — when the URL scheme is not HTTPS.