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

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