diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 13f2006..e17b1a7 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -173,6 +173,9 @@ class AuthController extends Controller return; } + // Regenerate session ID to prevent session fixation + session_regenerate_id(true); + // Login successful - create session $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index e6fe7b9..f35baac 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -579,8 +579,11 @@ class DomainController extends Controller $availableTags = $tagModel->getAllWithUsage(); } - // Get referrer for cancel button + // Get referrer for cancel button (validated to prevent open redirect / XSS) $referrer = $_GET['from'] ?? '/domains/' . $domain['id']; + if (!preg_match('#^/[a-zA-Z0-9]#', $referrer)) { + $referrer = '/domains/' . $domain['id']; + } $this->view('domains/edit', [ 'domain' => $domain, @@ -1619,9 +1622,19 @@ class DomainController extends Controller return; } + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + $deleted = 0; foreach ($domainIds as $id) { - if ($this->domainModel->delete($id)) { + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($id, $userId); + } else { + $domain = $this->domainModel->find($id); + } + + if ($domain && $this->domainModel->delete($id)) { $deleted++; } } @@ -1658,24 +1671,28 @@ class DomainController extends Controller return; } + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + // Validate notification group in isolation mode - if ($groupId) { - $settingModel = new \App\Models\Setting(); - $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); - - if ($isolationMode === 'isolated') { - $group = $this->groupModel->find($groupId); - if (!$group || $group['user_id'] != $userId) { - $_SESSION['error'] = 'You can only assign domains to your own notification groups'; - $this->redirect('/domains'); - return; - } + if ($groupId && $isolationMode === 'isolated') { + $group = $this->groupModel->find($groupId); + if (!$group || $group['user_id'] != $userId) { + $_SESSION['error'] = 'You can only assign domains to your own notification groups'; + $this->redirect('/domains'); + return; } } $updated = 0; foreach ($domainIds as $id) { - if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) { + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($id, $userId); + } else { + $domain = $this->domainModel->find($id); + } + + if ($domain && $this->domainModel->update($id, ['notification_group_id' => $groupId])) { $updated++; } } diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 0f2246e..29f8ed8 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -315,6 +315,9 @@ class InstallerController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/install'); + // Block re-installation if already installed if ($this->isInstalled()) { $_SESSION['error'] = 'System is already installed. Use the update function instead.'; @@ -364,11 +367,11 @@ class InstallerController extends Controller $file = __DIR__ . '/../../database/migrations/000_initial_schema_v1.1.0.sql'; $sql = file_get_contents($file); - // Replace admin credentials + // Replace admin credentials (use PDO::quote to prevent SQL injection) $passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT); - $sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql); - $sql = str_replace('{{ADMIN_USERNAME}}', $adminUsername, $sql); - $sql = str_replace('{{ADMIN_EMAIL}}', $adminEmail, $sql); + $sql = str_replace("'{{ADMIN_PASSWORD_HASH}}'", $pdo->quote($passwordHash), $sql); + $sql = str_replace("'{{ADMIN_USERNAME}}'", $pdo->quote($adminUsername), $sql); + $sql = str_replace("'{{ADMIN_EMAIL}}'", $pdo->quote($adminEmail), $sql); // Execute the entire consolidated schema at once // This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS @@ -584,6 +587,9 @@ class InstallerController extends Controller $this->redirect('/install/update'); return; } + + // CSRF Protection + $this->verifyCsrf('/install/update'); try { $pdo = \Core\Database::getConnection(); diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index 033f7a7..18150e3 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -687,6 +687,13 @@ class NotificationGroupController extends Controller return; } + $channel = $this->channelModel->find($id); + if (!$channel || (int)$channel['notification_group_id'] !== (int)$groupId) { + $_SESSION['error'] = 'Channel not found'; + $this->redirect("/groups/$groupId/edit"); + return; + } + try { $this->channelModel->delete($id); $_SESSION['success'] = 'Channel deleted successfully'; @@ -714,6 +721,13 @@ class NotificationGroupController extends Controller return; } + $channel = $this->channelModel->find($id); + if (!$channel || (int)$channel['notification_group_id'] !== (int)$groupId) { + $_SESSION['error'] = 'Channel not found'; + $this->redirect("/groups/$groupId/edit"); + return; + } + try { $this->channelModel->toggleActive($id); $_SESSION['success'] = 'Channel status updated'; diff --git a/app/Controllers/TwoFactorController.php b/app/Controllers/TwoFactorController.php index 60b7bac..5c8d5b3 100644 --- a/app/Controllers/TwoFactorController.php +++ b/app/Controllers/TwoFactorController.php @@ -275,6 +275,9 @@ class TwoFactorController extends Controller $this->twoFactorService->recordAttempt($userId, $ipAddress, $verified); if ($verified) { + // Regenerate session ID to prevent session fixation + session_regenerate_id(true); + // Clear 2FA requirement and complete login $pendingRemember = !empty($_SESSION['pending_remember']); unset($_SESSION['2fa_required']); @@ -343,6 +346,8 @@ class TwoFactorController extends Controller return; } + $this->verifyCsrf('/2fa/verify'); + try { // Check if user is in 2FA verification state if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) { diff --git a/app/Models/ErrorLog.php b/app/Models/ErrorLog.php index 0ec25a0..4aa48d8 100644 --- a/app/Models/ErrorLog.php +++ b/app/Models/ErrorLog.php @@ -247,7 +247,9 @@ class ErrorLog extends Model } $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; - $sortColumn = $filters['sort']; + + $allowedSort = ['error_id', 'error_type', 'error_message', 'is_resolved', 'occurred_at', 'last_occurred_at', 'occurrences']; + $sortColumn = in_array($filters['sort'], $allowedSort, true) ? $filters['sort'] : 'last_occurred_at'; $sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC'; $query = " diff --git a/app/Views/domains/bulk-add.twig b/app/Views/domains/bulk-add.twig index afc5def..3777aaa 100644 --- a/app/Views/domains/bulk-add.twig +++ b/app/Views/domains/bulk-add.twig @@ -74,7 +74,7 @@

Available Tags:

{% for tag in availableTags %} -