# LDAP Injection via Unsanitized Filter String Construction

Language: Python
Severity: Critical
CWE: CWE-90

## Source
2

## Flow
2-6-7

## Sink
7

## Vulnerable Code
```python
from ldap3 import Server, Connection, ALL
def fetch_iot_device_owner(device_serial, base_dn='ou=devices,dc=iotcloud,dc=local'):
    srv = Server('ldap://iot-directory.internal', get_info=ALL)
    conn = Connection(srv, user='cn=svcReader,dc=iotcloud,dc=local', password='R3ad0nly!')
    conn.bind()
    ldap_qry = f'(&(objectClass=iotDevice)(serialNumber={device_serial}))'
    conn.search(base_dn, ldap_qry, attributes=['ownerUid', 'deviceRole', 'accessToken'])
    if conn.entries:
        rec = conn.entries[0]
        return {'owner': str(rec['ownerUid']), 'role': str(rec['deviceRole']), 'token': str(rec['accessToken'])}
    return None
```

## Explanation

The `device_serial` parameter is accepted directly from the caller (untrusted external input) and interpolated without any escaping or sanitization into the LDAP search filter string via an f-string on line 6. This tainted filter is then passed directly to `conn.search()` on line 7, allowing an attacker to inject LDAP metacharacters that alter the filter logic and potentially dump all records including sensitive `accessToken` fields.

## Remediation

The fix applies two layers of defense against LDAP injection. First, the input is validated against a strict allowlist regex that only permits alphanumeric characters, hyphens, and underscores—rejecting any input containing LDAP metacharacters like `*`, `(`, `)`, `|`, or `\`. Second, `ldap3.utils.conv.escape_filter_chars()` is used to escape any remaining LDAP-special characters in the device serial before it is interpolated into the filter string, ensuring that even if validation were bypassed, the input would be treated as a literal value rather than filter syntax.

## Secure Code
```python
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
import re

def fetch_iot_device_owner(device_serial, base_dn='ou=devices,dc=iotcloud,dc=local'):
    # Validate device_serial format (alphanumeric, hyphens, and underscores only)
    if not device_serial or not re.match(r'^[a-zA-Z0-9_\-]+$', device_serial):
        raise ValueError("Invalid device serial number format")

    srv = Server('ldap://iot-directory.internal', get_info=ALL)
    conn = Connection(srv, user='cn=svcReader,dc=iotcloud,dc=local', password='R3ad0nly!')
    conn.bind()

    # Escape LDAP special characters to prevent injection
    safe_serial = escape_filter_chars(device_serial)
    ldap_qry = '(&(objectClass=iotDevice)(serialNumber={}))'.format(safe_serial)

    conn.search(base_dn, ldap_qry, attributes=['ownerUid', 'deviceRole', 'accessToken'])
    if conn.entries:
        rec = conn.entries[0]
        return {'owner': str(rec['ownerUid']), 'role': str(rec['deviceRole']), 'token': str(rec['accessToken'])}
    return None
```
