Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m56s
Lint / Deploy (push) Successful in 3s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
184 lines
5.8 KiB
PHP
184 lines
5.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* User Avatar API
|
|
*
|
|
* Serves profile pictures fetched from lldap via LDAP.
|
|
* Caches images locally to avoid repeated LDAP queries.
|
|
*
|
|
* GET /api/user_avatar.php?user_id=123
|
|
* Returns the user's JPEG avatar (from cache or LDAP).
|
|
* Returns 404 if the user has no avatar set in lldap.
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|
ErrorHandler::init();
|
|
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
|
|
// Must be authenticated
|
|
if (!isset($_SESSION['user']['user_id'])) {
|
|
http_response_code(401);
|
|
exit;
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|
http_response_code(405);
|
|
exit;
|
|
}
|
|
|
|
$cfg = $GLOBALS['config'];
|
|
|
|
// Validate user_id parameter
|
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
|
|
if ($userId <= 0) {
|
|
http_response_code(400);
|
|
exit;
|
|
}
|
|
|
|
// Ensure LDAP is enabled and extension is loaded
|
|
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
// Ensure avatar cache directory exists
|
|
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
|
|
if (!is_dir($cacheDir)) {
|
|
mkdir($cacheDir, 0755, true);
|
|
}
|
|
|
|
// Build cache paths from the validated integer $userId — no user-supplied strings used
|
|
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
|
|
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
|
|
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
|
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
|
|
|
// Serve from cache if fresh
|
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
|
header('Content-Type: image/jpeg');
|
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
|
header('X-Avatar-Source: cache');
|
|
readfile($cacheFile);
|
|
exit;
|
|
}
|
|
|
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
// Look up username from DB
|
|
try {
|
|
$conn = Database::getConnection();
|
|
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
|
|
$stmt->bind_param('i', $userId);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$row = $result->fetch_assoc();
|
|
$stmt->close();
|
|
|
|
if (!$row || empty($row['username'])) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
$username = $row['username'];
|
|
} catch (Exception $e) {
|
|
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
|
|
http_response_code(500);
|
|
exit;
|
|
}
|
|
|
|
// Query lldap via LDAP
|
|
$ldapHost = $cfg['LDAP_HOST'];
|
|
$ldapPort = $cfg['LDAP_PORT'];
|
|
$bindDn = $cfg['LDAP_BIND_DN'];
|
|
$bindPw = $cfg['LDAP_BIND_PW'];
|
|
$userBase = $cfg['LDAP_USER_BASE'];
|
|
|
|
// Escape username for LDAP filter (RFC 4515)
|
|
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
|
|
$filter = "(uid=$safeUsername)";
|
|
|
|
$avatarData = null;
|
|
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
|
|
|
|
try {
|
|
// 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");
|
|
}
|
|
|
|
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
|
|
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
|
|
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
|
|
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
|
|
|
|
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
|
|
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
|
|
}
|
|
|
|
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
|
|
if (!$search) {
|
|
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
|
|
}
|
|
|
|
$entries = ldap_get_entries($ldap, $search);
|
|
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
|
|
// ldap_get_entries() returns the attribute value as raw binary.
|
|
$avatarData = $entries[0]['avatar'][0];
|
|
}
|
|
|
|
// The query ran to completion — any "no avatar" result is authoritative.
|
|
$ldapQueryOk = true;
|
|
|
|
ldap_unbind($ldap);
|
|
} catch (Exception $e) {
|
|
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
|
// Transient LDAP failure: do NOT poison the negative cache. Fall through to
|
|
// a plain 404 so the avatar is retried on the next request once LDAP recovers.
|
|
}
|
|
|
|
if ($avatarData === null || strlen($avatarData) < 100) {
|
|
// Only cache "no avatar" when LDAP actually answered. On an error/timeout we
|
|
// leave no sentinel, so the lookup is retried instead of being stuck for the TTL.
|
|
if ($ldapQueryOk) {
|
|
file_put_contents($noAvatarSentinel, '');
|
|
}
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
// Validate it's actually a JPEG (magic bytes FF D8 FF). A successful LDAP read of
|
|
// non-JPEG data is a genuine "no usable avatar", so the sentinel is appropriate here.
|
|
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
|
error_log("user_avatar: non-JPEG data for username=$username");
|
|
file_put_contents($noAvatarSentinel, '');
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
// Cache to disk
|
|
file_put_contents($cacheFile, $avatarData);
|
|
// Remove stale sentinel if present
|
|
if (file_exists($noAvatarSentinel)) {
|
|
unlink($noAvatarSentinel);
|
|
}
|
|
|
|
header('Content-Type: image/jpeg');
|
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
|
header('X-Avatar-Source: ldap');
|
|
echo $avatarData;
|