# LDAP Injection via Unsanitized Distinguished Name Construction

Language: Python
Severity: Critical
CWE: CWE-90

## Source
5-6

## Flow
5-6-8

## Sink
8

## Vulnerable Code
```python
import ldap
def fetch_device_config(device_id, org_unit):
    conn = ldap.initialize('ldap://iot-directory.internal:389')
    conn.simple_bind_s('cn=svcacct,dc=corp,dc=io', 'S3rvice!Pass')
    dn_base = f'ou={org_unit},dc=corp,dc=io'
    search_filter = f'(deviceSerial={device_id})'
    attrs = ['configBlob', 'firmwareVer', 'accessToken']
    res = conn.search_s(dn_base, ldap.SCOPE_SUBTREE, search_filter, attrs)
    conn.unbind_s()
    return res
```

## Explanation

Both `org_unit` and `device_id` are directly interpolated into the LDAP Distinguished Name base (`dn_base`) and search filter (`search_filter`) without any sanitization or escaping. An attacker controlling either parameter can inject LDAP metacharacters (e.g., `*`, `)(`, `|`, `=`) to manipulate the DN structure or alter filter logic, bypassing intended access controls and accessing configurations/tokens across arbitrary OUs or devices.

## Remediation

The fix addresses LDAP injection by applying three layers of defense: (1) input validation using a strict allowlist regex that rejects any characters outside alphanumerics, hyphens, and underscores; (2) proper escaping of `org_unit` via `ldap.dn.escape_dn_chars()` for DN construction and `device_id` via `ldap.filter.escape_filter_chars()` for search filter construction, neutralizing any LDAP metacharacters; (3) removal of hardcoded credentials in favor of environment variables. A `try/finally` block also ensures the LDAP connection is properly unbound even if an error occurs.

## Secure Code
```python
import ldap
import ldap.dn
import ldap.filter
import os
import re


def _validate_simple_name(value, field_name):
    """Validate that a value contains only safe alphanumeric characters, hyphens, and underscores."""
    if not value or not isinstance(value, str):
        raise ValueError(f"{field_name} must be a non-empty string")
    if not re.match(r'^[a-zA-Z0-9_-]+$', value):
        raise ValueError(f"{field_name} contains invalid characters")
    return value


def fetch_device_config(device_id, org_unit):
    # Validate inputs against an allowlist of safe characters
    org_unit = _validate_simple_name(org_unit, "org_unit")
    device_id = _validate_simple_name(device_id, "device_id")

    # Read credentials from environment variables instead of hardcoding
    bind_dn = os.environ.get('LDAP_BIND_DN', 'cn=svcacct,dc=corp,dc=io')
    bind_password = os.environ.get('LDAP_BIND_PASSWORD')
    if not bind_password:
        raise RuntimeError("LDAP_BIND_PASSWORD environment variable is not set")

    conn = ldap.initialize('ldap://iot-directory.internal:389')
    try:
        conn.simple_bind_s(bind_dn, bind_password)

        # Escape the org_unit for safe use in the DN
        escaped_org_unit = ldap.dn.escape_dn_chars(org_unit)
        dn_base = f'ou={escaped_org_unit},dc=corp,dc=io'

        # Escape the device_id for safe use in the LDAP search filter
        escaped_device_id = ldap.filter.escape_filter_chars(device_id)
        search_filter = f'(deviceSerial={escaped_device_id})'

        attrs = ['configBlob', 'firmwareVer', 'accessToken']
        res = conn.search_s(dn_base, ldap.SCOPE_SUBTREE, search_filter, attrs)
    finally:
        conn.unbind_s()

    return res
```
