Skip to content

Commit 9456ea6

Browse files
committed
clean up
1 parent a744399 commit 9456ea6

File tree

6 files changed

+276
-20
lines changed

6 files changed

+276
-20
lines changed

internal_docs/specs/ldap-authentication/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ This table maps Phoenix's environment variables to Grafana's TOML configuration
6060
| **User Search Filter** | `PHOENIX_LDAP_USER_SEARCH_FILTER="(uid=%s)"` | `search_filter = "(uid=%s)"` | Identical: `%s` = username |
6161
| **Email Attribute** | `PHOENIX_LDAP_ATTR_EMAIL="mail"` | `[servers.attributes]`<br>`email = "mail"` | Phoenix: Direct env var<br>Grafana: Nested TOML section |
6262
| **Name Attribute** | `PHOENIX_LDAP_ATTR_DISPLAY_NAME="displayName"` | `name = "displayName"` | Both map display name |
63+
| **Unique ID** | `PHOENIX_LDAP_ATTR_UNIQUE_ID="objectGUID"` | Not supported | Phoenix: Optional immutable identifier for email change resilience |
6364
| **Group Membership** | `PHOENIX_LDAP_ATTR_MEMBER_OF="memberOf"` | `member_of = "memberOf"` | Active Directory attribute |
6465
| **Group Search Base** | `PHOENIX_LDAP_GROUP_SEARCH_BASE="ou=groups,..."` | `group_search_base_dns = ["ou=groups,dc=..."]` | For POSIX groups without memberOf |
6566
| **Group Search Filter** | `PHOENIX_LDAP_GROUP_SEARCH_FILTER="(member=%s)"` | `group_search_filter = "(member=%s)"` | Identical: `%s` = user DN |
6667
| **Group→Role Mapping** | `PHOENIX_LDAP_GROUP_ROLE_MAPPINGS='[`<br>` {"group_dn": "cn=admins,...", "role": "ADMIN"}`<br>`]'` | `[[servers.group_mappings]]`<br>`group_dn = "cn=admins,..."`<br>`org_role = "Admin"` | Phoenix: JSON with `role` (no org)<br>Grafana: TOML with `org_role` |
6768
| **Allow Sign-Up** | `PHOENIX_LDAP_ALLOW_SIGN_UP="true"` | `[auth.ldap]`<br>`allow_sign_up = true` | Both default to `true` |
68-
| **Unique ID** | `PHOENIX_LDAP_ATTR_UNIQUE_ID="objectGUID"` | Not supported | Phoenix: Optional immutable identifier for email change resilience |
6969
| **Timeout** | Not exposed (uses ldap3 defaults) | `timeout = 10` | Grafana: Configurable in seconds |
7070
| **Connection Pooling** | Not exposed (uses ldap3 defaults) | Not configurable | Both use underlying library defaults |
7171

internal_docs/specs/ldap-authentication/security.md

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
| **Event Loop DoS** | Slow LDAP connections | Service unavailability | Medium | Thread pool isolation, timeouts |
1313
| **Regex DoS** | CVE-2024-47764 in python-ldap | Service degradation | Very Low | Use ldap3 (not python-ldap) |
1414
| **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 |
1518

1619
---
1720

@@ -166,6 +169,58 @@ if not success:
166169

167170
---
168171

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+
169224
## TLS Configuration
170225

171226
**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
292347

293348
---
294349

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+
295504
## Socket Leak Prevention
296505

297506
**Threat**: Failed LDAP operations can leak file descriptors if connections are not properly cleaned up.
@@ -346,7 +555,15 @@ finally:
346555

347556
## Additional Security Resources
348557

558+
- [User Identification Strategy](./user-identification-strategy.md) - Email recycling protection, case normalization, migration logic
349559
- [Protocol Compliance](./protocol-compliance.md) - Anonymous bind prevention, ambiguous search rejection, DN validation
350560
- [Configuration Reference](./configuration.md) - TLS configuration options and security recommendations
351561
- [Grafana Comparison](./grafana-comparison.md) - Security patterns adopted from Grafana's implementation
352562

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+

internal_docs/specs/ldap-authentication/user-identification-strategy.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,28 @@ user = User(
147147

148148
## Unique ID Attribute Details
149149

150+
### Supported Attribute Types
151+
152+
**Only standard UUID-based attributes are supported:**
153+
154+
| Directory | Attribute | Format | Example |
155+
|-----------|-----------|--------|---------|
156+
| Active Directory | `objectGUID` | 16-byte binary (mixed-endian) | `0x...``550e8400-e29b-41d4-...` |
157+
| OpenLDAP | `entryUUID` | String UUID (36 bytes) | `550e8400-e29b-41d4-a716-...` |
158+
| 389 DS | `nsUniqueId` | String UUID | `550e8400-e29b-41d4-a716-...` |
159+
160+
**Not supported:**
161+
- Custom attributes containing 16-character string IDs (e.g., `"EMP12345ABCD6789"`)
162+
- Arbitrary binary formats that aren't UUIDs
163+
164+
**Why this limitation?**
165+
- 16-byte values are assumed to be binary UUIDs (AD `objectGUID`)
166+
- A 16-character string ID would be incorrectly converted via `uuid.UUID(bytes_le=...)`
167+
- This produces a garbled UUID that doesn't match the original string
168+
- Implementing a heuristic to distinguish them has a ~1 in 7.7 million false positive rate, which is unacceptable for large deployments
169+
170+
If your organization uses non-standard unique ID attributes, use **Simple Mode** (email-based identification) instead.
171+
150172
### Active Directory: objectGUID
151173

152174
- **Type**: Binary (16 bytes, mixed-endian per MS-DTYP §2.3.4)
@@ -291,6 +313,7 @@ Enterprise IAM systems use `objectGUID`/`entryUUID` as the primary identifier.
291313
| **Lowercase normalization** | UUIDs are case-insensitive (RFC 4122), prevents mismatches |
292314
| **Case-insensitive DB lookup** | Handles legacy data with different casing |
293315
| **Email recycling protection** | Prevents account hijacking via recycled emails |
316+
| **UUID-only unique_id support** | Heuristics for 16-char strings have unacceptable false positive rates |
294317

295318
## References
296319

src/phoenix/config.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,14 @@
371371
When set, this attribute is used as the primary identifier, allowing users
372372
to survive email changes without creating duplicate accounts.
373373
374-
Values by directory type:
375-
- Active Directory: "objectGUID"
376-
- OpenLDAP: "entryUUID" (RFC 4530)
377-
- 389 Directory Server: "nsUniqueId"
374+
Supported attributes (UUID-based only):
375+
- Active Directory: "objectGUID" (16-byte binary UUID)
376+
- OpenLDAP: "entryUUID" (RFC 4530, string UUID)
377+
- 389 Directory Server: "nsUniqueId" (string UUID)
378+
379+
IMPORTANT: Only standard UUID-based attributes are supported. Custom attributes
380+
containing 16-character string IDs (e.g., "EMP12345ABCD6789") are NOT supported
381+
and will be incorrectly converted.
378382
379383
When not set (default), email is used as the identifier. Both modes handle
380384
DN changes (OU moves, renames). The only difference is email change handling.
@@ -1423,6 +1427,12 @@ class LDAPConfig:
14231427
- OpenLDAP: "entryUUID" (RFC 4530)
14241428
- 389 DS: "nsUniqueId"
14251429
1430+
IMPORTANT: Only standard UUID-based attributes are supported. Custom attributes
1431+
containing 16-character string IDs (e.g., "EMP12345ABCD6789") are NOT supported
1432+
and will be incorrectly converted. The attribute must contain either:
1433+
- A 16-byte binary UUID (objectGUID)
1434+
- A string-format UUID (entryUUID, nsUniqueId)
1435+
14261436
Use this only if you expect user emails to change (company rebranding, M&A,
14271437
frequent name changes). Otherwise, email-based identification is simpler.
14281438
Survives: Everything including email changes.

src/phoenix/server/api/routers/auth.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import secrets
44
from datetime import datetime, timedelta, timezone
55
from functools import partial
6+
from typing import TYPE_CHECKING
67
from urllib.parse import urlencode, urlparse, urlunparse
78

89
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -45,6 +46,9 @@
4546
)
4647
from phoenix.server.utils import prepend_root_path
4748

49+
if TYPE_CHECKING:
50+
from phoenix.server.ldap import LDAPAuthenticator
51+
4852
logger = logging.getLogger(__name__)
4953

5054
rate_limiter = ServerRateLimiter(
@@ -321,7 +325,7 @@ def create_auth_router(ldap_enabled: bool = False) -> APIRouter:
321325
async def ldap_login(request: Request) -> Response:
322326
"""Authenticate user via LDAP and return access/refresh tokens."""
323327
# Use cached authenticator instance to avoid re-parsing TLS config on every request
324-
authenticator = getattr(request.app.state, "ldap_authenticator", None)
328+
authenticator: LDAPAuthenticator | None = getattr(request.app.state, "ldap_authenticator", None)
325329

326330
if not authenticator:
327331
raise HTTPException(

0 commit comments

Comments
 (0)