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 ?? [];