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

@@ -173,6 +173,9 @@ class AuthController extends Controller
return; return;
} }
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// Login successful - create session // Login successful - create session
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];

View File

@@ -579,8 +579,11 @@ class DomainController extends Controller
$availableTags = $tagModel->getAllWithUsage(); $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']; $referrer = $_GET['from'] ?? '/domains/' . $domain['id'];
if (!preg_match('#^/[a-zA-Z0-9]#', $referrer)) {
$referrer = '/domains/' . $domain['id'];
}
$this->view('domains/edit', [ $this->view('domains/edit', [
'domain' => $domain, 'domain' => $domain,
@@ -1619,9 +1622,19 @@ class DomainController extends Controller
return; return;
} }
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$deleted = 0; $deleted = 0;
foreach ($domainIds as $id) { 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++; $deleted++;
} }
} }
@@ -1658,24 +1671,28 @@ class DomainController extends Controller
return; return;
} }
// Validate notification group in isolation mode $settingModel = new \App\Models\Setting();
if ($groupId) { $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
if ($isolationMode === 'isolated') { // Validate notification group in isolation mode
$group = $this->groupModel->find($groupId); if ($groupId && $isolationMode === 'isolated') {
if (!$group || $group['user_id'] != $userId) { $group = $this->groupModel->find($groupId);
$_SESSION['error'] = 'You can only assign domains to your own notification groups'; if (!$group || $group['user_id'] != $userId) {
$this->redirect('/domains'); $_SESSION['error'] = 'You can only assign domains to your own notification groups';
return; $this->redirect('/domains');
} return;
} }
} }
$updated = 0; $updated = 0;
foreach ($domainIds as $id) { 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++; $updated++;
} }
} }

View File

@@ -315,6 +315,9 @@ class InstallerController extends Controller
return; return;
} }
// CSRF Protection
$this->verifyCsrf('/install');
// Block re-installation if already installed // Block re-installation if already installed
if ($this->isInstalled()) { if ($this->isInstalled()) {
$_SESSION['error'] = 'System is already installed. Use the update function instead.'; $_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'; $file = __DIR__ . '/../../database/migrations/000_initial_schema_v1.1.0.sql';
$sql = file_get_contents($file); $sql = file_get_contents($file);
// Replace admin credentials // Replace admin credentials (use PDO::quote to prevent SQL injection)
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT); $passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql); $sql = str_replace("'{{ADMIN_PASSWORD_HASH}}'", $pdo->quote($passwordHash), $sql);
$sql = str_replace('{{ADMIN_USERNAME}}', $adminUsername, $sql); $sql = str_replace("'{{ADMIN_USERNAME}}'", $pdo->quote($adminUsername), $sql);
$sql = str_replace('{{ADMIN_EMAIL}}', $adminEmail, $sql); $sql = str_replace("'{{ADMIN_EMAIL}}'", $pdo->quote($adminEmail), $sql);
// Execute the entire consolidated schema at once // Execute the entire consolidated schema at once
// This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS // This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS
@@ -585,6 +588,9 @@ class InstallerController extends Controller
return; return;
} }
// CSRF Protection
$this->verifyCsrf('/install/update');
try { try {
$pdo = \Core\Database::getConnection(); $pdo = \Core\Database::getConnection();
$migrations = $this->getPendingMigrations(); $migrations = $this->getPendingMigrations();

View File

@@ -687,6 +687,13 @@ class NotificationGroupController extends Controller
return; 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 { try {
$this->channelModel->delete($id); $this->channelModel->delete($id);
$_SESSION['success'] = 'Channel deleted successfully'; $_SESSION['success'] = 'Channel deleted successfully';
@@ -714,6 +721,13 @@ class NotificationGroupController extends Controller
return; 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 { try {
$this->channelModel->toggleActive($id); $this->channelModel->toggleActive($id);
$_SESSION['success'] = 'Channel status updated'; $_SESSION['success'] = 'Channel status updated';

View File

@@ -275,6 +275,9 @@ class TwoFactorController extends Controller
$this->twoFactorService->recordAttempt($userId, $ipAddress, $verified); $this->twoFactorService->recordAttempt($userId, $ipAddress, $verified);
if ($verified) { if ($verified) {
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// Clear 2FA requirement and complete login // Clear 2FA requirement and complete login
$pendingRemember = !empty($_SESSION['pending_remember']); $pendingRemember = !empty($_SESSION['pending_remember']);
unset($_SESSION['2fa_required']); unset($_SESSION['2fa_required']);
@@ -343,6 +346,8 @@ class TwoFactorController extends Controller
return; return;
} }
$this->verifyCsrf('/2fa/verify');
try { try {
// Check if user is in 2FA verification state // Check if user is in 2FA verification state
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) { if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {

View File

@@ -247,7 +247,9 @@ class ErrorLog extends Model
} }
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; $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'; $sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC';
$query = " $query = "

View File

@@ -74,7 +74,7 @@
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">Available Tags:</p> <p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{% for tag in availableTags %} {% for tag in availableTags %}
<button type="button" onclick="addTag('{{ tag.name }}')" <button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors"> class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
{{ tag.name }} {{ tag.name }}

View File

@@ -73,7 +73,7 @@
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p> <p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{% for tag in availableTags %} {% for tag in availableTags %}
<button type="button" onclick="addTag('{{ tag.name }}')" <button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors"> class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
{{ tag.name }} {{ tag.name }}

View File

@@ -76,7 +76,7 @@
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p> <p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{% for tag in availableTags %} {% for tag in availableTags %}
<button type="button" onclick="addTag('{{ tag.name }}')" <button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors"> class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
{{ tag.name }} {{ tag.name }}

View File

@@ -21,7 +21,7 @@
{% if domain.registrar_url is not empty %} {% if domain.registrar_url is not empty %}
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Registrar URL:</span> <span class="text-gray-500 dark:text-slate-400">Registrar URL:</span>
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center"> <a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i> <i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Visit
</a> </a>

View File

@@ -43,7 +43,7 @@
<div> <div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label> <label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
{% if domain.registrar_url is defined and domain.registrar_url %} {% if domain.registrar_url is defined and domain.registrar_url %}
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center"> <a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i> <i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Registrar Visit Registrar
</a> </a>
@@ -58,7 +58,7 @@
<div> <div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label> <label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
{% if domain.abuse_email %} {% if domain.abuse_email %}
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a> <a href="{{ domain.abuse_email|safe_mailto }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a>
{% else %} {% else %}
<span class="text-gray-400 dark:text-slate-500">-</span> <span class="text-gray-400 dark:text-slate-500">-</span>
{% endif %} {% endif %}

View File

@@ -98,7 +98,7 @@
{% if domain.registrar_url is not empty %} {% if domain.registrar_url is not empty %}
<div> <div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label> <label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center"> <a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i> <i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Visit
</a> </a>
@@ -107,7 +107,7 @@
{% if domain.abuse_email is not empty %} {% if domain.abuse_email is not empty %}
<div> <div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label> <label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"> <a href="{{ domain.abuse_email|safe_mailto }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
{{ domain.abuse_email }} {{ domain.abuse_email }}
</a> </a>
</div> </div>

View File

@@ -72,6 +72,7 @@
{% endif %} {% endif %}
<form method="POST" action="/install/update" class="space-y-3"> <form method="POST" action="/install/update" class="space-y-3">
{{ csrf_field() }}
<button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors"> <button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Run Update Now Run Update Now

View File

@@ -69,6 +69,7 @@
{% endif %} {% endif %}
<form method="POST" action="/install/run" class="space-y-5"> <form method="POST" action="/install/run" class="space-y-5">
{{ csrf_field() }}
<div class="border-t border-gray-200 pt-6"> <div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>

View File

@@ -291,7 +291,7 @@
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div> <div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div>
{% if tld.registry_url %} {% if tld.registry_url %}
<div class="text-sm text-gray-500 dark:text-slate-400"> <div class="text-sm text-gray-500 dark:text-slate-400">
<a href="{{ tld.registry_url }}" target="_blank" class="text-primary hover:text-primary-dark"> <a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary-dark">
<i class="fas fa-external-link-alt mr-1"></i> <i class="fas fa-external-link-alt mr-1"></i>
Registry Registry
</a> </a>
@@ -375,7 +375,7 @@
<div> <div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3> <h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3>
{% if tld.registry_url %} {% if tld.registry_url %}
<a href="{{ tld.registry_url }}" target="_blank" class="text-xs text-primary hover:text-primary-dark"> <a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:text-primary-dark">
<i class="fas fa-external-link-alt mr-1"></i> <i class="fas fa-external-link-alt mr-1"></i>
Registry Registry
</a> </a>

View File

@@ -60,7 +60,7 @@
{% if tld.registry_url %} {% if tld.registry_url %}
<div> <div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registry URL</label> <label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registry URL</label>
<a href="{{ tld.registry_url }}" target="_blank" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"> <a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i> <i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Registry Visit Registry
</a> </a>
@@ -229,7 +229,7 @@
</a> </a>
{% endif %} {% endif %}
{% if tld.registry_url %} {% if tld.registry_url %}
<a href="{{ tld.registry_url }}" target="_blank" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-all duration-200 group"> <a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-blue-50 dark:bg-blue-500/10 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 dark:text-blue-400 transition-colors duration-200"> <div class="w-9 h-9 bg-blue-50 dark:bg-blue-500/10 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 dark:text-blue-400 transition-colors duration-200">
<i class="fas fa-external-link-alt text-sm"></i> <i class="fas fa-external-link-alt text-sm"></i>
</div> </div>

View File

@@ -69,6 +69,15 @@ class SessionConfig
*/ */
public static function start(): void public static function start(): void
{ {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
session_start(); session_start();
// Validate session exists in database (for database-backed sessions) // Validate session exists in database (for database-backed sessions)

View File

@@ -239,6 +239,26 @@ class TwigService
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision); return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
})); }));
$this->twig->addFilter(new TwigFilter('safe_url', function (?string $url): string {
if ($url === null || $url === '') {
return '#';
}
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return '#';
}));
$this->twig->addFilter(new TwigFilter('safe_mailto', function (?string $email): string {
if ($email === null || $email === '') {
return '#';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
return 'mailto:' . $email;
}
return '#';
}));
$this->twig->addFilter(new TwigFilter('from_json', function ($value) { $this->twig->addFilter(new TwigFilter('from_json', function ($value) {
if ($value === null || $value === '') { if ($value === null || $value === '') {
return []; return [];

View File

@@ -47,12 +47,12 @@ $router->get('/2fa/verify', [TwoFactorController::class, 'showVerify']);
$router->post('/2fa/verify', [TwoFactorController::class, 'verify']); $router->post('/2fa/verify', [TwoFactorController::class, 'verify']);
$router->post('/2fa/send-email-code', [TwoFactorController::class, 'sendEmailCode']); $router->post('/2fa/send-email-code', [TwoFactorController::class, 'sendEmailCode']);
// Debug route (public - remove in production!)
$router->get('/debug/whois', [DebugController::class, 'whois']);
// Protected routes - require authentication // Protected routes - require authentication
Auth::require(); Auth::require();
// Debug route (admin-only)
$router->get('/debug/whois', [DebugController::class, 'whois']);
// Dashboard // Dashboard
$router->get('/', [DashboardController::class, 'index']); $router->get('/', [DashboardController::class, 'index']);
$router->get('/dashboard', [DashboardController::class, 'index']); $router->get('/dashboard', [DashboardController::class, 'index']);