security: fix auth bypass, XSS, IDOR, command injection, session hardening, rate limiting

- Phase 1: Require authentication for API register endpoint; restrict role creation
- Phase 2: Add $fillable whitelist to Server model to prevent mass assignment
- Phase 3: Fix XSS via escapeHtml in sidebar, JSON_HEX_TAG in script embeds
- Phase 4: Add canView() checks to TerminalController history/clearHistory
- Phase 5: escapeshellarg() on certbot params, sanitize service names, regex-based command blocklist, suppress exception messages
- Phase 6: SESSION_SECURE=true, SameSite=Strict, logout via POST+CSRF, chmod 640 .env
- Phase 7: DB-based rate limiting replacing session-based, applied to API login/register
This commit is contained in:
2026-06-14 07:32:18 -04:00
parent c16236314e
commit a5eb101d98
11 changed files with 145 additions and 38 deletions

View File

@@ -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,
]);

View File

@@ -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()]);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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']);