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:
+2
-2
@@ -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
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 — 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">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user