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:
Hosteroid
2026-03-11 00:03:54 +02:00
parent 36abf58838
commit e3006738a9
19 changed files with 112 additions and 34 deletions

View File

@@ -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++;
}
}