Improve security, validation, and isolation checks
Add multiple security and validation improvements across the app: - Prevent session fixation: regenerate session ID on login and after successful 2FA; tighten session cookie params (Secure, HttpOnly, SameSite=Lax). - Harden installer: add CSRF checks for install/update flows and use PDO::quote when injecting admin credentials into SQL migration to avoid injection; add csrf_field() to installer templates. - Template hardening: add safe_url and safe_mailto Twig filters, escape tag names for JS, and add rel="noopener noreferrer" to external links to mitigate XSS/opener risks. - Domain controller: validate referrer to avoid open redirects, enforce user isolation mode when finding/deleting/updating domains and when assigning notification groups (ensures users only affect their own resources). - Notification groups: verify channel belongs to group before deleting or toggling to prevent unauthorized access. - ErrorLog: whitelist allowed sort columns to avoid arbitrary column injection in ORDER BY. - Routes: move the debug whois route to protected/admin area. These changes collectively reduce attack surface (XSS, open redirect, session fixation, SQL injection) and enforce proper resource isolation and input validation.
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user