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/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/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); 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 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(); }