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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user