|
12 | 12 | | **Event Loop DoS** | Slow LDAP connections | Service unavailability | Medium | Thread pool isolation, timeouts | |
13 | 13 | | **Regex DoS** | CVE-2024-47764 in python-ldap | Service degradation | Very Low | Use ldap3 (not python-ldap) | |
14 | 14 | | **Misconfiguration** | Wrong group mappings | Privilege escalation | Medium | Validation on startup, dry-run mode | |
| 15 | +| **Referral Credential Leak** | Malicious referral to attacker server | Service account compromise | Medium | Disable referral following | |
| 16 | +| **Email Recycling Attack** | Recycled email hijacks old account | Data breach, privilege escalation | Medium | Unique ID conflict detection | |
| 17 | +| **UUID Case Mismatch** | Case-sensitive lookup misses user | Account lockout, duplicate accounts | Low | Case-insensitive lookup, lowercase normalization | |
15 | 18 |
|
16 | 19 | --- |
17 | 20 |
|
@@ -166,6 +169,58 @@ if not success: |
166 | 169 |
|
167 | 170 | --- |
168 | 171 |
|
| 172 | +## Referral Credential Leakage Prevention |
| 173 | + |
| 174 | +**Threat**: ldap3's default configuration follows LDAP referrals and sends credentials to any server. |
| 175 | + |
| 176 | +**Attack Scenario**: |
| 177 | +1. Attacker compromises or sets up a rogue LDAP server |
| 178 | +2. Legitimate LDAP server sends a referral: `ldap://attacker.com/...` |
| 179 | +3. ldap3 follows the referral and sends service account credentials to attacker |
| 180 | +4. Attacker captures `bind_dn` and `bind_password` |
| 181 | + |
| 182 | +**ldap3 Default Behavior** (VULNERABLE): |
| 183 | +```python |
| 184 | +# ldap3/core/server.py - DEFAULT allows ANY host with credentials! |
| 185 | +if allowed_referral_hosts is None: |
| 186 | + allowed_referral_hosts = [('*', True)] # Allow all hosts, send credentials |
| 187 | +``` |
| 188 | + |
| 189 | +**Additional Risk with STARTTLS**: |
| 190 | +```python |
| 191 | +# ldap3/strategy/base.py - Referral TLS config ignores original settings! |
| 192 | +tls=Tls(...) if selected_referral['ssl'] else None # STARTTLS referrals get NO TLS config! |
| 193 | +``` |
| 194 | + |
| 195 | +For STARTTLS referrals to `ldap://` URLs: |
| 196 | +- Original connection: `Tls(validate=ssl.CERT_REQUIRED)` ✅ |
| 197 | +- Referral creates: `Tls()` with default `validate=ssl.CERT_NONE` ❌ |
| 198 | +- Result: MITM possible on referral connections! |
| 199 | + |
| 200 | +**Mitigation** (Phoenix implementation): |
| 201 | +```python |
| 202 | +# Disable referral following on ALL Connection objects |
| 203 | +Connection( |
| 204 | + server, |
| 205 | + user=bind_dn, |
| 206 | + password=bind_password, |
| 207 | + auto_referrals=False, # SECURITY: Prevent credential leakage |
| 208 | + ... |
| 209 | +) |
| 210 | +``` |
| 211 | + |
| 212 | +**Why Disable Instead of Restrict?** |
| 213 | +- Phoenix already has multi-server failover for high availability |
| 214 | +- Referrals are typically used for cross-domain queries (not needed for authentication) |
| 215 | +- No legitimate use case for following referrals in Phoenix's LDAP flow |
| 216 | + |
| 217 | +**Affected Connections** (all three): |
| 218 | +1. Service account connection (`_establish_connection`) |
| 219 | +2. Anonymous bind connection (`_establish_connection`) |
| 220 | +3. User password verification (`_verify_user_password`) |
| 221 | + |
| 222 | +--- |
| 223 | + |
169 | 224 | ## TLS Configuration |
170 | 225 |
|
171 | 226 | **Phoenix TLS Implementation** (see `_create_servers()` in `ldap.py`): |
@@ -292,6 +347,160 @@ Thread pool isolation via `anyio.to_thread.run_sync()` is the standard approach |
292 | 347 |
|
293 | 348 | --- |
294 | 349 |
|
| 350 | +## Email Recycling Attack Prevention |
| 351 | + |
| 352 | +**Threat**: In enterprise mode (unique_id configured), a new employee with a recycled email could hijack an old employee's account. |
| 353 | + |
| 354 | +**Attack Scenario** (without protection): |
| 355 | +1. User A leaves company (DB: `[email protected]`, `oauth2_user_id=UUID-A`) |
| 356 | +2. User B joins with recycled email (LDAP: `[email protected]`, `unique_id=UUID-B`) |
| 357 | +3. User B logs in: |
| 358 | + - unique_id lookup: `UUID-B` not found in DB |
| 359 | + - email fallback: finds User A's account |
| 360 | + - **Vulnerable code would update User A's `oauth2_user_id` to `UUID-B`** |
| 361 | + - User B now has access to User A's data! |
| 362 | + |
| 363 | +**Mitigation** (Phoenix implementation): |
| 364 | +```python |
| 365 | +# Only migrate if user has no existing unique_id |
| 366 | +if user.oauth2_user_id is None: |
| 367 | + user.oauth2_user_id = unique_id # Safe: first-time migration |
| 368 | +elif user.oauth2_user_id.lower() != unique_id.lower(): |
| 369 | + # Different person - reject (email is unique in DB, can't create new account) |
| 370 | + raise HTTPException( |
| 371 | + status_code=403, |
| 372 | + detail="Account conflict: this email is associated with a different " |
| 373 | + "LDAP account. Contact your administrator.", |
| 374 | + ) |
| 375 | +``` |
| 376 | + |
| 377 | +**Why 403 Instead of Creating New Account?** |
| 378 | +- Email is unique in the database (`CREATE UNIQUE INDEX ix_users_email`) |
| 379 | +- Attempting to create a new user with the same email would fail with constraint violation |
| 380 | +- Explicit 403 gives users a clear error and directs them to admin |
| 381 | + |
| 382 | +**Resolution Options** (admin intervention required): |
| 383 | +1. Delete the old account (if user truly left) |
| 384 | +2. Update the old account's `oauth2_user_id` to the new UUID |
| 385 | +3. Change the old account's email to free it up |
| 386 | + |
| 387 | +--- |
| 388 | + |
| 389 | +## UUID Case Normalization |
| 390 | + |
| 391 | +**Threat**: Case-sensitive UUID lookups can cause account lockout or duplicate accounts. |
| 392 | + |
| 393 | +**Scenario**: |
| 394 | +1. Old Phoenix version stored: `oauth2_user_id = "550E8400-..."` (uppercase) |
| 395 | +2. New Phoenix normalizes to: `unique_id = "550e8400-..."` (lowercase) |
| 396 | +3. Case-sensitive lookup fails → user locked out or duplicate created |
| 397 | + |
| 398 | +**Mitigation**: |
| 399 | + |
| 400 | +1. **Normalize output to lowercase** (in `_get_unique_id`): |
| 401 | +```python |
| 402 | +# UUIDs are case-insensitive per RFC 4122 |
| 403 | +return decoded.lower() # Normalize entryUUID |
| 404 | +return str(uuid.UUID(bytes_le=...)) # uuid.UUID always returns lowercase |
| 405 | +``` |
| 406 | + |
| 407 | +2. **Case-insensitive database lookup**: |
| 408 | +```python |
| 409 | +# Use func.lower() for case-insensitive comparison |
| 410 | +.where(func.lower(models.User.oauth2_user_id) == unique_id.lower()) |
| 411 | +``` |
| 412 | + |
| 413 | +3. **Case-insensitive conflict detection**: |
| 414 | +```python |
| 415 | +# Compare lowercase to handle legacy data |
| 416 | +elif user.oauth2_user_id.lower() != unique_id.lower(): |
| 417 | + raise HTTPException(403, ...) |
| 418 | +``` |
| 419 | + |
| 420 | +**Result**: Existing users with different UUID casing are found and updated on next login. |
| 421 | + |
| 422 | +--- |
| 423 | + |
| 424 | +## Unique ID Extraction Robustness |
| 425 | + |
| 426 | +**Threat**: Malformed or edge-case LDAP attribute values could cause crashes or incorrect IDs. |
| 427 | + |
| 428 | +**Edge Cases Handled**: |
| 429 | + |
| 430 | +| Input | Handling | Result | |
| 431 | +|-------|----------|--------| |
| 432 | +| Missing attribute | `getattr(entry, attr_name, None)` | `None` | |
| 433 | +| Empty attribute (`values=[]`) | Check `attr is None` | `None` | |
| 434 | +| Empty bytes (`b""`) | Length check | `None` | |
| 435 | +| Whitespace-only (`b" "`) | Strip then check | `None` | |
| 436 | +| 16-byte binary (objectGUID) | `uuid.UUID(bytes_le=...)` | Lowercase UUID | |
| 437 | +| String UUID as bytes (entryUUID) | UTF-8 decode, lowercase | Lowercase UUID | |
| 438 | +| Uppercase UUID | `.lower()` normalization | Lowercase UUID | |
| 439 | +| Invalid UTF-8 binary | `.hex()` fallback | Hex string | |
| 440 | + |
| 441 | +**Implementation** (defensive coding): |
| 442 | +```python |
| 443 | +def _get_unique_id(entry: Any, attr_name: str) -> Optional[str]: |
| 444 | + attr = getattr(entry, attr_name, None) |
| 445 | + if attr is None: |
| 446 | + return None |
| 447 | + |
| 448 | + raw_value = attr.raw_values[0] if hasattr(attr, "raw_values") and attr.raw_values else None |
| 449 | + if raw_value is None: |
| 450 | + return None |
| 451 | + |
| 452 | + if isinstance(raw_value, (bytes, bytearray, memoryview)): |
| 453 | + raw_bytes = bytes(raw_value) |
| 454 | + if len(raw_bytes) == 0: |
| 455 | + return None |
| 456 | + if len(raw_bytes) == 16: |
| 457 | + return str(uuid.UUID(bytes_le=raw_bytes)) # Always lowercase |
| 458 | + else: |
| 459 | + try: |
| 460 | + decoded = raw_bytes.decode("utf-8").strip() |
| 461 | + return decoded.lower() if decoded else None |
| 462 | + except UnicodeDecodeError: |
| 463 | + return raw_bytes.hex() # Hex is already lowercase |
| 464 | + |
| 465 | + result = str(raw_value).strip() |
| 466 | + return result.lower() if result else None |
| 467 | +``` |
| 468 | + |
| 469 | +--- |
| 470 | + |
| 471 | +## Configuration Validation |
| 472 | + |
| 473 | +**Threat**: Typos in LDAP attribute names cause silent failures. |
| 474 | + |
| 475 | +**Scenario**: |
| 476 | +```bash |
| 477 | +# Typo: space in attribute name |
| 478 | +PHOENIX_LDAP_ATTR_UNIQUE_ID="object GUID" # Should be "objectGUID" |
| 479 | +``` |
| 480 | + |
| 481 | +The LDAP server returns no results for `"object GUID"` (attribute doesn't exist), causing all users to fail authentication with "missing unique_id" errors. |
| 482 | + |
| 483 | +**Mitigation** (startup validation in `config.py`): |
| 484 | +```python |
| 485 | +# Validate attribute names don't contain spaces |
| 486 | +for attr_var, attr_val in [ |
| 487 | + ("PHOENIX_LDAP_ATTR_EMAIL", attr_email), |
| 488 | + ("PHOENIX_LDAP_ATTR_DISPLAY_NAME", attr_display_name), |
| 489 | + ("PHOENIX_LDAP_ATTR_MEMBER_OF", attr_member_of), |
| 490 | + ("PHOENIX_LDAP_ATTR_UNIQUE_ID", attr_unique_id), |
| 491 | +]: |
| 492 | + if attr_val and " " in attr_val: |
| 493 | + raise ValueError( |
| 494 | + f"{attr_var} contains spaces: '{attr_val}'. " |
| 495 | + f"LDAP attribute names cannot contain spaces. " |
| 496 | + f"Did you mean '{attr_val.replace(' ', '')}'?" |
| 497 | + ) |
| 498 | +``` |
| 499 | + |
| 500 | +**Result**: Configuration errors caught at startup with helpful suggestions. |
| 501 | + |
| 502 | +--- |
| 503 | + |
295 | 504 | ## Socket Leak Prevention |
296 | 505 |
|
297 | 506 | **Threat**: Failed LDAP operations can leak file descriptors if connections are not properly cleaned up. |
@@ -346,7 +555,15 @@ finally: |
346 | 555 |
|
347 | 556 | ## Additional Security Resources |
348 | 557 |
|
| 558 | +- [User Identification Strategy](./user-identification-strategy.md) - Email recycling protection, case normalization, migration logic |
349 | 559 | - [Protocol Compliance](./protocol-compliance.md) - Anonymous bind prevention, ambiguous search rejection, DN validation |
350 | 560 | - [Configuration Reference](./configuration.md) - TLS configuration options and security recommendations |
351 | 561 | - [Grafana Comparison](./grafana-comparison.md) - Security patterns adopted from Grafana's implementation |
352 | 562 |
|
| 563 | +## References |
| 564 | + |
| 565 | +- [RFC 4122 - UUID URN Namespace](https://www.rfc-editor.org/rfc/rfc4122.html) - UUIDs are case-insensitive |
| 566 | +- [RFC 4515 - LDAP Search Filter](https://www.rfc-editor.org/rfc/rfc4515.html) - Filter escaping rules |
| 567 | +- [MS-DTYP §2.3.4 - GUID Structure](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/001eec5a-7f8b-4293-9e21-ca349392db40) - objectGUID binary format |
| 568 | +- [ldap3 Referral Handling](https://ldap3.readthedocs.io/en/latest/referrals.html) - Default `auto_referrals=True` behavior |
| 569 | + |
0 commit comments