From 98d30cbc589745e8acb040e7b2850e5da117d05f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 11:42:21 -0400 Subject: [PATCH 1/3] Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files opened their own raw new mysqli(...) connection instead of using Database::getConnection(), missing the charset/timezone sync every other connection gets. index.php's connection serves nearly all non-API web traffic (dashboard, ticket view, ticket create) — any NOW()/CURDATE()-based query through it used the DB server's default session timezone instead of the app's configured TIMEZONE, and no explicit utf8mb4 charset meant multi-byte characters typed into a ticket via the non-JS POST fallback could get corrupted at write time. create_ticket_api.php (the hwmonDaemon Bearer endpoint) had the same timezone gap, risking created_at landing on the wrong 'day' relative to every other ticket-creation path. index.php's raw die("Connection failed: " . $conn->connect_error) also leaked raw mysqli error text (host/user/failure reason) to any unauthenticated visitor on a DB outage; it now logs via error_log() and shows a generic message instead. create_ticket_api.php already handled this correctly (JSON error + error_log, no leak) and needed no behavior change there beyond the connection source. Verified against real MariaDB: the new connection path reports the configured -04:00 session time_zone and utf8mb4 charset, vs. SYSTEM tz on the old raw-mysqli path; a simulated connection failure (bad DB_NAME) confirmed only the generic message reaches the response body while the raw driver error goes to error_log(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv --- create_ticket_api.php | 56 +++++++++++++------------------------------ index.php | 18 +++++++------- 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/create_ticket_api.php b/create_ticket_api.php index e4a85a9..17d35be 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -8,9 +8,10 @@ ini_set('display_errors', 0); require_once __DIR__ . '/middleware/RateLimitMiddleware.php'; RateLimitMiddleware::apply('api'); -// Load environment variables with error check -$envFile = __DIR__ . '/.env'; -if (!file_exists($envFile)) { +// Early friendly JSON error if .env is missing, before config.php's own +// (plain-text die()) handling would otherwise run — this is a JSON API +// endpoint and must always respond with a JSON body. +if (!file_exists(__DIR__ . '/.env')) { echo json_encode([ 'success' => false, 'error' => 'Configuration file not found' @@ -18,37 +19,17 @@ if (!file_exists($envFile)) { exit; } -$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED); -if (!$envVars) { - echo json_encode([ - 'success' => false, - 'error' => 'Invalid configuration file' - ]); - exit; -} +// Load application config so UrlHelper can resolve APP_DOMAIN, and so the +// DB connection below (via Database::getConnection()) gets the same +// charset/timezone sync as every other endpoint instead of a hand-rolled +// second connection. +require_once __DIR__ . '/config/config.php'; +require_once __DIR__ . '/helpers/Database.php'; -// Strip quotes from values if present (parse_ini_file may include them) -foreach ($envVars as $key => $value) { - if (is_string($value)) { - if ( - (substr($value, 0, 1) === '"' && substr($value, -1) === '"') || - (substr($value, 0, 1) === "'" && substr($value, -1) === "'") - ) { - $envVars[$key] = substr($value, 1, -1); - } - } -} - -// Database connection with detailed error handling -$conn = new mysqli( - $envVars['DB_HOST'], - $envVars['DB_USER'], - $envVars['DB_PASS'], - $envVars['DB_NAME'] -); - -if ($conn->connect_error) { - error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error); +try { + $conn = Database::getConnection(); +} catch (\Throwable $e) { + error_log('create_ticket_api: DB connection failed: ' . $e->getMessage()); http_response_code(500); echo json_encode([ 'success' => false, @@ -57,9 +38,6 @@ if ($conn->connect_error) { exit; } -// Load application config so UrlHelper can resolve APP_DOMAIN -require_once __DIR__ . '/config/config.php'; - // Authenticate via API key require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/models/AuditLogModel.php'; @@ -349,7 +327,7 @@ if ($existing) { (new StatsModel($conn))->invalidateCache(); } - $conn->close(); + Database::close(); echo json_encode([ 'success' => true, 'ticket_id' => $existingId, @@ -386,7 +364,7 @@ if ($existing) { // Ticket reopened (Closed → Open) — refresh dashboard stats. (new StatsModel($conn))->invalidateCache(); - $conn->close(); + Database::close(); require_once __DIR__ . '/helpers/NotificationHelper.php'; NotificationHelper::sendTicketNotification($existingId, [ @@ -484,7 +462,7 @@ if ($inserted) { // New ticket created — refresh dashboard stats. (new StatsModel($conn))->invalidateCache(); - $conn->close(); + Database::close(); require_once __DIR__ . '/helpers/NotificationHelper.php'; NotificationHelper::sendTicketNotification($ticket_id, [ diff --git a/index.php b/index.php index d5982c0..248f1cd 100644 --- a/index.php +++ b/index.php @@ -5,6 +5,7 @@ require_once 'config/config.php'; require_once 'middleware/SecurityHeadersMiddleware.php'; require_once 'middleware/AuthMiddleware.php'; require_once 'models/AuditLogModel.php'; +require_once 'helpers/Database.php'; // Apply security headers early SecurityHeadersMiddleware::apply(); @@ -17,15 +18,12 @@ $requestPath = strtok($request, '?'); // Create database connection for non-API routes if (!str_starts_with($requestPath, '/api/')) { - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - die("Connection failed: " . $conn->connect_error); + try { + $conn = Database::getConnection(); + } catch (\Throwable $e) { + error_log('index.php: database connection failed: ' . $e->getMessage()); + http_response_code(500); + die('Sorry, something went wrong. Please try again shortly.'); } // Authenticate user via Authelia forward auth @@ -444,5 +442,5 @@ switch (true) { // Close database connection if it was opened if (isset($conn)) { - $conn->close(); + Database::close(); } From c78d24154ad7445879784430ef1f47b20c93e069 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 11:42:29 -0400 Subject: [PATCH 2/3] Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRUSTED_PROXIES ships empty in .env.example, which disables AuthMiddleware's reverse-proxy allowlist entirely — a fresh deployment that doesn't explicitly set it has zero verification that Remote-User/Remote-Groups headers actually came from the trusted Authelia proxy. Anything that can reach the app directly (a misconfigured firewall rule, an exposed container port, SSRF from another internal service) can set Remote-User: admin and fully impersonate any user with zero authentication. The enforcement logic itself was already correct; this was purely a dangerous, easy-to-miss default. Added a boxed, unmissable warning around TRUSTED_PROXIES in .env.example (previously just an inline comment easy to skim past), added the same warning to README's setup instructions (which didn't mention this variable at all), and added a Check 8 to api/health.php that reports a 'warning' status when TRUSTED_PROXIES is empty, so a deployment that forgets it doesn't go unnoticed after the fact. Verified against real MariaDB via a running server: the health endpoint correctly reports 'warning' when empty and 'ok' once set. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv --- .env.example | 15 +++++++++++---- README.md | 15 +++++++++++++++ api/health.php | 15 +++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 4b76f6f..a015805 100644 --- a/.env.example +++ b/.env.example @@ -49,19 +49,26 @@ APP_DOMAIN= ; Include all domains that can access this application ALLOWED_HOSTS=localhost,127.0.0.1 +; ============================================================================ +; REQUIRED FOR PRODUCTION -- READ BEFORE DEPLOYING -- TRUSTED_PROXIES +; ============================================================================ ; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy. ; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth ; headers (Remote-User / Remote-Groups) and forwarded client IPs are only ; trusted when REMOTE_ADDR is in this list. ; ; Leaving this EMPTY disables reverse-proxy verification entirely: the app then -; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if -; the PHP backend is reachable directly (bypassing the proxy), because a client -; can then spoof those headers and log in as an admin. Only leave it empty when -; network topology guarantees PHP is reachable solely via the trusted proxy. +; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP +; backend is reachable directly -- a misconfigured firewall rule, a container +; network accidentally exposing the port, SSRF from another internal service +; -- ANYONE can set Remote-User: admin themselves and fully impersonate any +; user, including an admin, with ZERO authentication. Only leave it empty when +; network topology guarantees PHP is reachable solely via the trusted proxy +; (e.g. local development), never in a real deployment. ; ; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27 ; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28 +; ============================================================================ TRUSTED_PROXIES= ; Timezone (default: America/New_York) diff --git a/README.md b/README.md index ac755b4..7f0cabd 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,21 @@ APP_DOMAIN=your.domain.example TIMEZONE=America/New_York ``` +**⚠️ REQUIRED FOR PRODUCTION — `TRUSTED_PROXIES`:** This app trusts Authelia +forward-auth headers (`Remote-User`, `Remote-Groups`, etc.) to identify who's +logged in. `TRUSTED_PROXIES` restricts that trust to requests that actually +came through your reverse proxy — **leaving it empty disables that check +entirely**, and anyone who can reach the PHP backend directly (a +misconfigured firewall rule, an exposed container port, SSRF from another +internal service) can set `Remote-User: admin` themselves and fully +impersonate any user with zero authentication. Set it to your reverse proxy's +IP address(es) before deploying anywhere reachable beyond your own machine: +```env +TRUSTED_PROXIES=10.10.10.27 +``` +`GET /api/health.php` reports a `warning` on the `trusted_proxies` check if +this is left empty, so it doesn't go unnoticed after deployment. + Matrix notification variables (all optional): ```env # hookshot generic webhook URL — send events to Matrix room diff --git a/api/health.php b/api/health.php index 8be48e4..dabf0fd 100644 --- a/api/health.php +++ b/api/health.php @@ -162,6 +162,21 @@ if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time' ]; } +// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s +// allowlist entirely, meaning anything that can reach this app directly can +// spoof the Authelia forward-auth Remote-* headers and impersonate any user, +// including an admin. Not fatal (a fresh/dev install may not sit behind a +// proxy yet), but should never go unnoticed on a real deployment. +if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) { + $checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured']; +} else { + $checks['trusted_proxies'] = [ + 'status' => 'warning', + 'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; ' + . 'anything that can reach this app directly can impersonate any user' + ]; +} + // Calculate response time $responseTime = round((microtime(true) - $startTime) * 1000, 2); From 9d8a73c3551c5931e220ba50329ab5c687654028 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 11:42:46 -0400 Subject: [PATCH 3/3] Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/bootstrap.php's centralized CSRF handling echoes CsrfMiddleware::getToken() on a 403 rejection specifically so lt.api's client-side resync (assets/js/base.js) can recover once window.CSRF_TOKEN goes stale (token expiry, or a write in another tab rotating the shared session-scoped token). 12 endpoints duplicate CsrfMiddleware::validateToken() inline instead of routing through bootstrap.php, and their 403 body omitted csrf_token entirely — custom_fields.php, clone_ticket.php, delete_comment.php, delete_attachment.php, bulk_operation.php, generate_api_key.php, manage_templates.php, manage_recurring.php, revoke_api_key.php, manage_workflows.php, ticket_dependencies.php, and upload_attachment.php. Once a client's token drifted out of sync, the next write to any of these 12 endpoints returned a 403 with no way to self-heal — every subsequent write to any endpoint kept failing until a manual reload, since the resync mechanism was only wired up on a minority of the app's write surface. Took the minimal fix the issue names as sufficient (add 'csrf_token' => CsrfMiddleware::getToken() to each rejection body) rather than restructuring all 12 through bootstrap.php, to avoid behavioral risk from rewiring each endpoint's differing auth/bootstrapping. generate_api_key.php and revoke_api_key.php threw a generic Exception for this case (swallowed into a plain error-message response with no room for extra fields), so those two now short-circuit with a direct JSON response instead, matching the other 10. Verified end-to-end against real running endpoints with a real session and real MariaDB: sent a wrong CSRF token to one endpoint of each response shape (plain json_encode, ResponseHelper::error, and the formerly exception-based path) and confirmed all three now return the current valid csrf_token in the 403 body. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv --- api/bulk_operation.php | 2 +- api/clone_ticket.php | 2 +- api/custom_fields.php | 2 +- api/delete_attachment.php | 2 +- api/delete_comment.php | 2 +- api/generate_api_key.php | 9 ++++++++- api/manage_recurring.php | 2 +- api/manage_templates.php | 2 +- api/manage_workflows.php | 2 +- api/revoke_api_key.php | 9 ++++++++- api/ticket_dependencies.php | 2 +- api/upload_attachment.php | 2 +- 12 files changed, 26 insertions(+), 12 deletions(-) diff --git a/api/bulk_operation.php b/api/bulk_operation.php index fe4f521..7029c7f 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -25,7 +25,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/clone_ticket.php b/api/clone_ticket.php index bb41a16..6862277 100644 --- a/api/clone_ticket.php +++ b/api/clone_ticket.php @@ -34,7 +34,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } diff --git a/api/custom_fields.php b/api/custom_fields.php index 2ca16a9..fa3b5ba 100644 --- a/api/custom_fields.php +++ b/api/custom_fields.php @@ -40,7 +40,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/delete_attachment.php b/api/delete_attachment.php index 3a8ade7..8a4cf82 100644 --- a/api/delete_attachment.php +++ b/api/delete_attachment.php @@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Verify CSRF token $csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } // Get attachment ID diff --git a/api/delete_comment.php b/api/delete_comment.php index 9b11935..8576497 100644 --- a/api/delete_comment.php +++ b/api/delete_comment.php @@ -49,7 +49,7 @@ try { if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } diff --git a/api/generate_api_key.php b/api/generate_api_key.php index 855d9e3..92fdf19 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -39,8 +39,15 @@ try { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { + ob_end_clean(); http_response_code(403); - throw new Exception("Invalid CSRF token"); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Invalid CSRF token', + 'csrf_token' => CsrfMiddleware::getToken() + ]); + exit; } } diff --git a/api/manage_recurring.php b/api/manage_recurring.php index 79c196e..85cb156 100644 --- a/api/manage_recurring.php +++ b/api/manage_recurring.php @@ -42,7 +42,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/manage_templates.php b/api/manage_templates.php index e89067d..31bcc56 100644 --- a/api/manage_templates.php +++ b/api/manage_templates.php @@ -39,7 +39,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/manage_workflows.php b/api/manage_workflows.php index 6961d2f..e6d12aa 100644 --- a/api/manage_workflows.php +++ b/api/manage_workflows.php @@ -40,7 +40,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/revoke_api_key.php b/api/revoke_api_key.php index fe2bb7f..beede25 100644 --- a/api/revoke_api_key.php +++ b/api/revoke_api_key.php @@ -39,8 +39,15 @@ try { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { + ob_end_clean(); http_response_code(403); - throw new Exception("Invalid CSRF token"); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Invalid CSRF token', + 'csrf_token' => CsrfMiddleware::getToken() + ]); + exit; } } diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php index f75b3d5..d969156 100644 --- a/api/ticket_dependencies.php +++ b/api/ticket_dependencies.php @@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } } diff --git a/api/upload_attachment.php b/api/upload_attachment.php index df0ed80..43c8625 100644 --- a/api/upload_attachment.php +++ b/api/upload_attachment.php @@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { // Verify CSRF token $csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } // Get ticket ID