Merge development into main: error-handling rollout + session privilege re-sync (#38, #39, #56, #105)

- Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
- Periodically re-sync session privileges from Authelia (#56)
This commit is contained in:
2026-09-11 12:23:49 -04:00
28 changed files with 197 additions and 45 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+5
View File
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
'SESSION_TIMEOUT' => 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
+2 -2
View File
@@ -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');
+26 -5
View File
@@ -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');
}
+7
View File
@@ -1,6 +1,13 @@
<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
+72
View File
@@ -92,6 +92,19 @@ class AuthMiddleware
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
// Periodically re-validate Remote-User/Remote-Groups against
// current Authelia/LLDAP state, so a revoked admin (or anyone
// dropped from the required groups) loses access promptly
// instead of keeping it for up to SESSION_TIMEOUT. Only the
// idle timer was checked above; nothing previously re-read
// these headers once a session already existed.
$resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300;
$lastSync = $_SESSION['last_privilege_sync'] ?? 0;
if (time() - $lastSync > $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.
*
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-danger">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>