diff --git a/database/migrations/015_rate_limits.sql b/database/migrations/015_rate_limits.sql new file mode 100644 index 0000000..ee80a40 --- /dev/null +++ b/database/migrations/015_rate_limits.sql @@ -0,0 +1,8 @@ +-- Migration 015: Rate limiting storage +CREATE TABLE IF NOT EXISTS rate_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + identifier VARCHAR(255) NOT NULL, + attempts INT NOT NULL DEFAULT 1, + first_attempt_at BIGINT NOT NULL, + UNIQUE KEY idx_identifier (identifier) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/routes/web.php b/routes/web.php index 8a844d8..9d3da81 100755 --- a/routes/web.php +++ b/routes/web.php @@ -31,7 +31,7 @@ $router->get('/login', [AuthController::class, 'loginForm']); $router->post('/login', [AuthController::class, 'login'], [CSRFMiddleware::class, RateLimitMiddleware::class]); $router->get('/register', [AuthController::class, 'registerForm']); $router->post('/register', [AuthController::class, 'register'], [CSRFMiddleware::class]); -$router->get('/logout', [AuthController::class, 'logout']); +$router->post('/logout', [AuthController::class, 'logout'], [CSRFMiddleware::class]); $router->get('/profile', [AuthController::class, 'profile'], [AuthMiddleware::class]); $router->post('/profile', [AuthController::class, 'updateProfile'], [AuthMiddleware::class, CSRFMiddleware::class]); @@ -115,8 +115,8 @@ $router->put('/api/servers/:id', [ApiController::class, 'serverUpdate']); $router->delete('/api/servers/:id', [ApiController::class, 'serverDelete']); $router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']); $router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand']); -$router->post('/api/auth/login', [ApiController::class, 'login']); -$router->post('/api/auth/register', [ApiController::class, 'register']); +$router->post('/api/auth/login', [ApiController::class, 'login'], [RateLimitMiddleware::class]); +$router->post('/api/auth/register', [ApiController::class, 'register'], [RateLimitMiddleware::class]); $router->get('/api/app-version', [ApiController::class, 'appVersion']); $router->get('/api/servers/:id/services', [ApiController::class, 'serverServices']); $router->post('/api/servers/:id/reboot', [ApiController::class, 'serverReboot']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 5643cf1..9ac6c43 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -341,6 +341,25 @@ class ApiController $this->view->json(['error' => 'Command is required'], 400); } + $blockedPatterns = [ + '/\brm\s+\-rf\s+\//', + '/\bmkfs\b/', + '/\bdd\s+if\=/', + '/\:\s*\{\s*:\s*\|[^\}]*\}\s*;/', + '/\bshutdown\b/', + '/\breboot\b/', + '/\bhalt\b/', + '/\bpoweroff\b/', + ]; + foreach ($blockedPatterns as $pattern) { + if (preg_match($pattern, $command)) { + $this->view->json([ + 'success' => false, + 'error' => 'This command has been blocked for safety reasons.', + ], 400); + } + } + $serverModel = new Server(); $server = $serverModel->findById($id); @@ -378,10 +397,9 @@ class ApiController ]); } catch (\Throwable $e) { $this->view->json([ - 'error' => 'Command execution failed: ' . $e->getMessage(), + 'error' => 'Command execution failed.', ], 500); } - } public function users(): void { @@ -496,6 +514,12 @@ class ApiController public function register(): void { + $user = $this->authenticateRequest(); + + if (!in_array($user['role'], ['super_admin', 'admin'], true)) { + $this->view->json(['error' => 'Forbidden'], 403); + } + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; $validator = new Validator(); @@ -504,6 +528,7 @@ class ApiController 'email' => 'required|email', 'password' => 'required|min:8', 'confirm_password' => 'required', + 'role' => 'in:operator,admin', ]; if (!$validator->validate($input, $rules)) { @@ -522,6 +547,13 @@ class ApiController return; } + $role = in_array($input['role'] ?? 'operator', ['operator', 'admin'], true) + ? $input['role'] : 'operator'; + + if ($role === 'admin' && $user['role'] !== 'super_admin') { + $this->view->json(['error' => 'Only super admins can create admin users'], 403); + } + $userModel = new User(); $existing = $userModel->findByUsername($input['username']); @@ -546,7 +578,7 @@ class ApiController 'username' => $input['username'], 'email' => $input['email'], 'password' => $input['password'], - 'role' => 'admin', + 'role' => $role, 'is_active' => 1, ]); diff --git a/src/Controllers/ServerController.php b/src/Controllers/ServerController.php index 32b7e1b..075e814 100755 --- a/src/Controllers/ServerController.php +++ b/src/Controllers/ServerController.php @@ -1261,8 +1261,8 @@ class ServerController $this->view->json(['success' => false, 'message' => 'Domain is required']); } - $domainList = preg_replace('/\s+/', ',', $domains); - $emailArg = !empty($email) ? "--email {$email}" : '--register-unsafely-without-email'; + $domainList = escapeshellarg(preg_replace('/\s+/', ',', $domains)); + $emailArg = !empty($email) ? '--email ' . escapeshellarg($email) : '--register-unsafely-without-email'; $cmd = "sudo certbot --nginx -d {$domainList} {$emailArg} --non-interactive --agree-tos 2>&1; echo EXIT:\$?"; $result = $ssh->exec($cmd); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); @@ -1773,22 +1773,27 @@ class ServerController $this->view->json(['success' => false, 'message' => 'Invalid action or service']); } + $sanitizedService = preg_replace('/[^a-zA-Z0-9\-_@.]/', '', $service); + if ($sanitizedService === '') { + $this->view->json(['success' => false, 'message' => 'Invalid service name']); + } + try { $ssh = new SSHService(); if (!$ssh->connect($server)) { throw new \RuntimeException('Could not establish SSH connection'); } - $this->auditService->logServerAction($id, "service_{$action}", "{$service} {$action}"); + $this->auditService->logServerAction($id, "service_{$action}", "{$sanitizedService} {$action}"); - $result = $ssh->exec("sudo systemctl {$action} {$service} 2>&1; echo EXIT:\$?"); + $result = $ssh->exec("sudo systemctl {$action} {$sanitizedService} 2>&1; echo EXIT:\$?"); $exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1'); $ssh->disconnect(); $this->view->json([ - 'success' => $exitCode === 0, - 'message' => $exitCode === 0 ? "Service '{$service}' {$action}ed." : "Failed to {$action} '{$service}'.", - 'output' => trim($result['output']), - ]); + 'success' => $exitCode === 0, + 'message' => $exitCode === 0 ? "Service '{$sanitizedService}' {$action}ed." : "Failed to {$action} '{$sanitizedService}'.", + 'output' => trim($result['output']), + ]); } catch (\Throwable $e) { $this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]); } diff --git a/src/Controllers/TerminalController.php b/src/Controllers/TerminalController.php index 668bfbf..7565150 100755 --- a/src/Controllers/TerminalController.php +++ b/src/Controllers/TerminalController.php @@ -66,14 +66,22 @@ class TerminalController $this->view->json(['success' => false, 'message' => 'Command is required']); } - $blockedCommands = ['rm -rf /', 'mkfs', 'dd if=', ':(){ :|:& };:']; - foreach ($blockedCommands as $blocked) { - if (stripos($command, $blocked) !== false) { + $blockedPatterns = [ + '/\brm\s+\-rf\s+\//', + '/\bmkfs\b/', + '/\bdd\s+if\=/', + '/\:\s*\{\s*:\s*\|[^\}]*\}\s*;/', + '/\bshutdown\b/', + '/\breboot\b/', + '/\bhalt\b/', + '/\bpoweroff\b/', + ]; + foreach ($blockedPatterns as $pattern) { + if (preg_match($pattern, $command)) { $this->auditService->log('blocked_command', 'server', $id, [ 'command' => $command, 'reason' => 'Blocked dangerous command pattern', ]); - $this->view->json([ 'success' => false, 'message' => 'This command has been blocked for safety reasons.', @@ -111,13 +119,17 @@ class TerminalController } catch (\Throwable $e) { $this->view->json([ 'success' => false, - 'message' => 'Failed to execute command: ' . $e->getMessage(), + 'message' => 'Failed to execute command.', ]); } - } public function history(int $id): void { + $userId = (int) Session::get('user_id'); + if (!$this->serverModel->canView($id, $userId)) { + $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); + } + $commandHistory = new CommandHistory(); $history = $commandHistory->getByServer($id, 100); @@ -129,6 +141,11 @@ class TerminalController public function clearHistory(int $id): void { + $userId = (int) Session::get('user_id'); + if (!$this->serverModel->canView($id, $userId)) { + $this->view->json(['success' => false, 'message' => 'Forbidden'], 403); + } + $commandHistory = new CommandHistory(); $commandHistory->clearHistory($id); diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php index a1f173c..2a77fa2 100755 --- a/src/Middleware/RateLimitMiddleware.php +++ b/src/Middleware/RateLimitMiddleware.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ServerManager\Middleware; -use ServerManager\Core\Session; use ServerManager\Core\Database; use ServerManager\Core\App; @@ -17,24 +16,50 @@ class RateLimitMiddleware $decayMinutes = $config['decay_minutes'] ?? 15; $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; - $key = 'rate_limit_' . $ip . '_' . ($_SERVER['REQUEST_URI'] ?? '/'); + $identifier = hash('sha256', $ip . '_' . ($_SERVER['REQUEST_URI'] ?? '/')); + $now = time(); + $windowStart = $now - ($decayMinutes * 60); - $attempts = Session::get($key, ['count' => 0, 'first_attempt' => 0]); + $db = Database::getInstance(); - if ($attempts['first_attempt'] && (time() - $attempts['first_attempt']) > ($decayMinutes * 60)) { - $attempts = ['count' => 0, 'first_attempt' => 0]; + $existing = $db->fetch( + 'SELECT attempts, first_attempt_at FROM rate_limits WHERE identifier = ?', + [$identifier] + ); + + if ($existing) { + if ((int) $existing['first_attempt_at'] < $windowStart) { + $db->query( + 'UPDATE rate_limits SET attempts = 1, first_attempt_at = ? WHERE identifier = ?', + [$now, $identifier] + ); + $attempts = 1; + } else { + $attempts = (int) $existing['attempts'] + 1; + $db->query( + 'UPDATE rate_limits SET attempts = ? WHERE identifier = ?', + [$attempts, $identifier] + ); + } + } else { + $db->insert('rate_limits', [ + 'identifier' => $identifier, + 'attempts' => 1, + 'first_attempt_at' => $now, + ]); + $attempts = 1; } - $attempts['count']++; - - if ($attempts['first_attempt'] === 0) { - $attempts['first_attempt'] = time(); + if (mt_rand(1, 100) <= 5) { + $db->query( + 'DELETE FROM rate_limits WHERE first_attempt_at < ?', + [$windowStart] + ); } - Session::set($key, $attempts); - - if ($attempts['count'] > $maxAttempts) { - $retryAfter = ($decayMinutes * 60) - (time() - $attempts['first_attempt']); + if ($attempts > $maxAttempts) { + $firstAttemptAt = $existing ? (int) $existing['first_attempt_at'] : $now; + $retryAfter = ($decayMinutes * 60) - ($now - $firstAttemptAt); if ($this->isApiRequest()) { http_response_code(429); diff --git a/src/Models/Server.php b/src/Models/Server.php index 395a7c6..58055d2 100755 --- a/src/Models/Server.php +++ b/src/Models/Server.php @@ -15,6 +15,16 @@ class Server private Encryption $encryption; private array $encryptedFields = ['ssh_password', 'ssh_key']; + private array $fillable = [ + 'name', 'ip_address', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', + 'description', 'group_name', 'is_active', 'current_status', 'status', + 'agent_key', 'agent_installed', 'agent_install_path', 'agent_last_seen_at', + 'created_by', 'created_at', 'updated_at', + 'threshold_cpu_warning', 'threshold_cpu_critical', + 'threshold_ram_warning', 'threshold_ram_critical', + 'threshold_disk_warning', 'threshold_disk_critical', + ]; + public function __construct() { $this->db = Database::getInstance(); @@ -108,8 +118,14 @@ class Server ]; } + private function filterData(array $data): array + { + return array_intersect_key($data, array_flip($this->fillable)); + } + public function create(array $data): int { + $data = $this->filterData($data); if (!empty($data['ssh_password'])) { $data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']); } else { @@ -143,6 +159,7 @@ class Server public function update(int $id, array $data): bool { + $data = $this->filterData($data); if (array_key_exists('ssh_password', $data)) { if (!empty($data['ssh_password'])) { $data['ssh_password'] = $this->encryption->encrypt($data['ssh_password']); diff --git a/views/dashboard/index.php b/views/dashboard/index.php index 3ca08f3..6c6deb6 100755 --- a/views/dashboard/index.php +++ b/views/dashboard/index.php @@ -165,7 +165,7 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];