From 863f84f37eec6e87b446679e2d426f6ea1d25e72 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 00:56:00 -0400 Subject: [PATCH] Switch LDAP avatar lookups to LDAPS (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/user_avatar.php connected via ldap://$ldapHost:$ldapPort — never ldaps://, and there was no ldap_start_tls() call anywhere in the codebase. LDAP_BIND_PW was sent over the wire unencrypted on every avatar fetch. Switched to ldaps://, and changed LDAP_HOST/LDAP_PORT's defaults to ldap.lotusguild.org:6360 (lldap's LDAPS listener) instead of the bare IP on port 3890 (plaintext). PHP's ldap extension verifies the server cert's hostname by default, so a bare IP won't validate against the LDAPS cert (issued for *.lotusguild.org) — LDAP_HOST has to be a hostname the cert covers. This is deliberately not configurable back to plaintext ldap://. Infra change (pve-infra, separate repo/commit): added a Pi-hole split-horizon override so ldap.lotusguild.org resolves internally to the real LDAP server's LAN IP — its existing public DNS record points elsewhere (an unrelated host), and there was no internal-only DNS entry for it before this. Verified against the real lldap server (pct 147, LDAPS on 6360, a live Let's Encrypt *.lotusguild.org cert): confirmed the Pi-hole override resolves correctly from hosts using it as their resolver, then ran the exact ldap_connect/ldap_bind sequence via `php -r` directly on the production tinker_tickets host (10.10.10.45) with a deliberately wrong bind password — got "Invalid credentials" (a real LDAP protocol response), not a transport/TLS error, proving the full connect + TLS handshake + hostname verification + bind path works end-to-end in the actual deployment environment. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV --- .env.example | 10 +++++++--- api/user_avatar.php | 5 ++++- config/config.php | 16 +++++++++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index ce2d412..36e7aed 100644 --- a/.env.example +++ b/.env.example @@ -74,10 +74,14 @@ TRUSTED_PROXIES= ; Timezone (default: America/New_York) TIMEZONE=America/New_York -; LDAP / lldap (for user avatar lookups) +; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is +; lldap's default LDAPS port, NOT its plaintext port (3890), since +; LDAP_BIND_PW below would otherwise go over the wire unencrypted. +; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname +; verification is not skipped), not a bare IP. LDAP_ENABLED=true -LDAP_HOST=10.10.10.39 -LDAP_PORT=3890 +LDAP_HOST=ldap.lotusguild.org +LDAP_PORT=6360 LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com" LDAP_BIND_PW= LDAP_BASE_DN="dc=example,dc=com" diff --git a/api/user_avatar.php b/api/user_avatar.php index 58393fc..1bb7d7a 100644 --- a/api/user_avatar.php +++ b/api/user_avatar.php @@ -113,7 +113,10 @@ $avatarData = null; $ldapQueryOk = false; // true only if the LDAP lookup completed without error try { - $ldap = @ldap_connect("ldap://$ldapHost:$ldapPort"); + // LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below, + // and lldap's plaintext port (3890) would put it on the wire unencrypted. + // lldap's LDAPS listener defaults to port 6360 (see config.php). + $ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort"); if (!$ldap) { throw new RuntimeException("ldap_connect failed"); } diff --git a/config/config.php b/config/config.php index 14b4234..99454b9 100644 --- a/config/config.php +++ b/config/config.php @@ -154,9 +154,19 @@ $GLOBALS['config'] = [ 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', 'TIMEZONE_OFFSET' => null, // Will be calculated below - // LDAP / lldap settings (for user avatar lookups) - 'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39', - 'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890), + // LDAP / lldap settings (for user avatar lookups). Connects over LDAPS + // (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not + // its plaintext port 3890. The bind password must never go over the + // wire unencrypted, so this is not configurable back to a plaintext + // ldap:// connection. + // + // LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org + // CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's + // hostname by default and a mismatch fails the connection. Pi-hole has a + // split-horizon override so ldap.lotusguild.org resolves internally to + // the real LDAP server IP (its public DNS record points elsewhere). + 'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org', + 'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360), 'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com', 'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '', 'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',