diff --git a/api/add_comment.php b/api/add_comment.php index 6fecce9..0a17e8b 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -1,8 +1,8 @@ 18000, // 5 hours in seconds 'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes + // How often an already-logged-in session re-validates Remote-User/ + // Remote-Groups against current Authelia/LLDAP state (AuthMiddleware). + // Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT. + 'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes + // CSRF settings 'CSRF_LIFETIME' => 3600, // 1 hour in seconds diff --git a/create_ticket_api.php b/create_ticket_api.php index 17d35be..d8e8b21 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -2,8 +2,8 @@ header('Content-Type: application/json'); -error_reporting(E_ALL); -ini_set('display_errors', 0); +require_once __DIR__ . '/helpers/ErrorHandler.php'; +ErrorHandler::init(); require_once __DIR__ . '/middleware/RateLimitMiddleware.php'; RateLimitMiddleware::apply('api'); diff --git a/helpers/ErrorHandler.php b/helpers/ErrorHandler.php index e12a0cd..d05cd2b 100644 --- a/helpers/ErrorHandler.php +++ b/helpers/ErrorHandler.php @@ -10,26 +10,36 @@ class ErrorHandler { private static ?string $logFile = null; private static bool $initialized = false; + private static string $responseMode = 'json'; /** * Initialize error handling * - * @param bool $displayErrors Whether to display errors (false in production) + * @param bool $displayErrors Whether to display errors (false in production) + * @param string $responseMode 'json' (API endpoints) or 'html' (page views — + * renders views/error_500.php instead of a JSON body) */ - public static function init(bool $displayErrors = false): void + public static function init(bool $displayErrors = false, string $responseMode = 'json'): void { if (self::$initialized) { return; } + self::$responseMode = $responseMode; + // Set error reporting error_reporting(E_ALL); ini_set('display_errors', $displayErrors ? '1' : '0'); ini_set('log_errors', '1'); - // Set up log file - self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log'; - ini_set('error_log', self::$logFile); + // Deliberately does NOT override the 'error_log' ini setting: doing so + // used to redirect every error_log() call in the request to a fixed + // /tmp file, silently diverting logs away from wherever the server is + // actually configured to send them (php-fpm's error_log, stdout in a + // container, etc.) the moment this got wired into more than one + // endpoint. self::$logFile / getRecentErrors() are unused (no callers + // app-wide) and exist only as an opt-in helper if something later + // wants a dedicated log file. // Register handlers set_error_handler([self::class, 'handleError']); @@ -151,6 +161,17 @@ class ErrorHandler { http_response_code($httpCode); + if (self::$responseMode === 'html') { + if (!headers_sent()) { + header('Content-Type: text/html; charset=utf-8'); + } + // Deliberately not passed $message/$exception — see error_500.php's + // docblock on why the fatal-error page must render with zero + // dependency on request-specific state. + include dirname(__DIR__) . '/views/error_500.php'; + exit; + } + if (!headers_sent()) { header('Content-Type: application/json'); } diff --git a/index.php b/index.php index 248f1cd..f18bc99 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,13 @@ $resyncInterval) { + $this->resyncPrivileges(); + } + return $_SESSION['user']; } } @@ -134,6 +147,7 @@ class AuthMiddleware // Store user in session $_SESSION['user'] = $user; $_SESSION['last_activity'] = time(); + $_SESSION['last_privilege_sync'] = time(); // Generate new CSRF token on login require_once __DIR__ . '/CsrfMiddleware.php'; @@ -142,6 +156,64 @@ class AuthMiddleware return $user; } + /** + * Re-validate the current session's Remote-User/Remote-Groups against + * this request's forward-auth headers, and re-sync or revoke access on + * mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an + * already-authenticated session — see authenticate(). + * + * Best-effort: if this particular request doesn't carry forward-auth + * headers at all (e.g. a proxy hiccup), the session is left as-is rather + * than force-logging the user out, and the check is simply retried on + * the next request past the interval. + */ + private function resyncPrivileges(): void + { + $username = $this->getHeader('HTTP_REMOTE_USER'); + $groups = $this->getHeader('HTTP_REMOTE_GROUPS'); + + if (empty($username)) { + return; + } + + $this->enforceTrustedProxy(); + + // A different Remote-User than the session's own means Authelia is + // now asserting a different identity entirely for this proxy path; + // don't silently relabel the session as that other user. + if ($username !== ($_SESSION['user']['username'] ?? null)) { + return; + } + + if (!$this->checkGroupAccess($groups)) { + $this->logSecurityEvent('privilege_resync_revoked', [ + 'username' => $username, + 'groups' => $groups ?: 'none', + ]); + session_unset(); + session_destroy(); + $this->redirectToAuth(); + exit; + } + + $displayName = $this->getHeader('HTTP_REMOTE_NAME'); + $email = $this->getHeader('HTTP_REMOTE_EMAIL'); + + // Bypass UserModel's 5-minute in-process cache — that cache key isn't + // group-aware, so a stale cached hit here would silently keep serving + // the pre-revocation is_admin value for the rest of the cache's TTL. + UserModel::invalidateCache(null, $username); + $user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups); + + $wasAdmin = !empty($_SESSION['user']['is_admin']); + if ($wasAdmin && empty($user['is_admin'])) { + $this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]); + } + + $_SESSION['user'] = $user; + $_SESSION['last_privilege_sync'] = time(); + } + /** * Reject forward-auth headers that did not arrive via a trusted proxy. * diff --git a/views/error_500.php b/views/error_500.php new file mode 100644 index 0000000..b6c5695 --- /dev/null +++ b/views/error_500.php @@ -0,0 +1,38 @@ + + + +
+ + ++ An unexpected error occurred. It's been logged; please try again shortly. +
+ ← Dashboard +