Merge development into main: connection/security hardening batch (#85, #94, #103, #104)

- Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104)
- Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
- Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85)
This commit is contained in:
2026-09-11 11:54:36 -04:00
17 changed files with 92 additions and 65 deletions
+11 -4
View File
@@ -49,19 +49,26 @@ APP_DOMAIN=
; Include all domains that can access this application ; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1 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. ; 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 ; 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 ; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
; trusted when REMOTE_ADDR is in this list. ; trusted when REMOTE_ADDR is in this list.
; ;
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then ; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if ; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
; the PHP backend is reachable directly (bypassing the proxy), because a client ; backend is reachable directly -- a misconfigured firewall rule, a container
; can then spoof those headers and log in as an admin. Only leave it empty when ; network accidentally exposing the port, SSRF from another internal service
; network topology guarantees PHP is reachable solely via the trusted proxy. ; -- 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 ; 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 ; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
; ============================================================================
TRUSTED_PROXIES= TRUSTED_PROXIES=
; Timezone (default: America/New_York) ; Timezone (default: America/New_York)
+15
View File
@@ -447,6 +447,21 @@ APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York 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): Matrix notification variables (all optional):
```env ```env
# hookshot generic webhook URL — send events to Matrix room # hookshot generic webhook URL — send events to Matrix room
+1 -1
View File
@@ -25,7 +25,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
} }
+1 -1
View File
@@ -34,7 +34,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
+1 -1
View File
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verify CSRF token // Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token'); ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
} }
// Get attachment ID // Get attachment ID
+1 -1
View File
@@ -49,7 +49,7 @@ try {
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json'); 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; exit;
} }
+8 -1
View File
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403); 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;
} }
} }
+15
View File
@@ -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 // Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2); $responseTime = round((microtime(true) - $startTime) * 1000, 2);
+1 -1
View File
@@ -42,7 +42,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403); 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; exit;
} }
} }
+8 -1
View File
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403); 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;
} }
} }
+1 -1
View File
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token'); ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
} }
} }
+1 -1
View File
@@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// Verify CSRF token // Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) { if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token'); ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
} }
// Get ticket ID // Get ticket ID
+17 -39
View File
@@ -8,9 +8,10 @@ ini_set('display_errors', 0);
require_once __DIR__ . '/middleware/RateLimitMiddleware.php'; require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api'); RateLimitMiddleware::apply('api');
// Load environment variables with error check // Early friendly JSON error if .env is missing, before config.php's own
$envFile = __DIR__ . '/.env'; // (plain-text die()) handling would otherwise run — this is a JSON API
if (!file_exists($envFile)) { // endpoint and must always respond with a JSON body.
if (!file_exists(__DIR__ . '/.env')) {
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => 'Configuration file not found' 'error' => 'Configuration file not found'
@@ -18,37 +19,17 @@ if (!file_exists($envFile)) {
exit; exit;
} }
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED); // Load application config so UrlHelper can resolve APP_DOMAIN, and so the
if (!$envVars) { // DB connection below (via Database::getConnection()) gets the same
echo json_encode([ // charset/timezone sync as every other endpoint instead of a hand-rolled
'success' => false, // second connection.
'error' => 'Invalid configuration file' require_once __DIR__ . '/config/config.php';
]); require_once __DIR__ . '/helpers/Database.php';
exit;
}
// Strip quotes from values if present (parse_ini_file may include them) try {
foreach ($envVars as $key => $value) { $conn = Database::getConnection();
if (is_string($value)) { } catch (\Throwable $e) {
if ( error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
(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);
http_response_code(500); http_response_code(500);
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
@@ -57,9 +38,6 @@ if ($conn->connect_error) {
exit; exit;
} }
// Load application config so UrlHelper can resolve APP_DOMAIN
require_once __DIR__ . '/config/config.php';
// Authenticate via API key // Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php'; require_once __DIR__ . '/models/AuditLogModel.php';
@@ -349,7 +327,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
} }
$conn->close(); Database::close();
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'ticket_id' => $existingId, 'ticket_id' => $existingId,
@@ -386,7 +364,7 @@ if ($existing) {
// Ticket reopened (Closed → Open) — refresh dashboard stats. // Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
$conn->close(); Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [ NotificationHelper::sendTicketNotification($existingId, [
@@ -484,7 +462,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats. // New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
$conn->close(); Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [ NotificationHelper::sendTicketNotification($ticket_id, [
+8 -10
View File
@@ -5,6 +5,7 @@ require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php'; require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php'; require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php'; require_once 'models/AuditLogModel.php';
require_once 'helpers/Database.php';
// Apply security headers early // Apply security headers early
SecurityHeadersMiddleware::apply(); SecurityHeadersMiddleware::apply();
@@ -17,15 +18,12 @@ $requestPath = strtok($request, '?');
// Create database connection for non-API routes // Create database connection for non-API routes
if (!str_starts_with($requestPath, '/api/')) { if (!str_starts_with($requestPath, '/api/')) {
$conn = new mysqli( try {
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'], } catch (\Throwable $e) {
$GLOBALS['config']['DB_PASS'], error_log('index.php: database connection failed: ' . $e->getMessage());
$GLOBALS['config']['DB_NAME'] http_response_code(500);
); die('Sorry, something went wrong. Please try again shortly.');
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
} }
// Authenticate user via Authelia forward auth // Authenticate user via Authelia forward auth
@@ -444,5 +442,5 @@ switch (true) {
// Close database connection if it was opened // Close database connection if it was opened
if (isset($conn)) { if (isset($conn)) {
$conn->close(); Database::close();
} }