diff --git a/db/migrations/004_rate_limits.sql b/db/migrations/004_rate_limits.sql new file mode 100644 index 0000000..2adc7f5 --- /dev/null +++ b/db/migrations/004_rate_limits.sql @@ -0,0 +1,8 @@ +-- Migration 004: API rate limiting table +CREATE TABLE IF NOT EXISTS api_rate_limits ( + ip VARCHAR(45) NOT NULL, + endpoint VARCHAR(64) NOT NULL DEFAULT 'api', + hits INT UNSIGNED NOT NULL DEFAULT 1, + window_start INT UNSIGNED NOT NULL, + PRIMARY KEY (ip, endpoint) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/panel/api/endpoints/sessions.php b/panel/api/endpoints/sessions.php new file mode 100644 index 0000000..3680e9d --- /dev/null +++ b/panel/api/endpoints/sessions.php @@ -0,0 +1,57 @@ +require('admin'); +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$db = DB::getInstance(); +$me = Auth::getInstance()->user(); +$method = $_SERVER['REQUEST_METHOD']; + +match (true) { + + $action === 'list' && $method === 'GET' => (function() use ($db) { + $rows = $db->fetchAll( + "SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at, + u.username, u.email, u.role + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.expires_at > NOW() + ORDER BY s.created_at DESC + LIMIT 200" + ) ?: []; + Response::json(['success' => true, 'data' => $rows]); + })(), + + $action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) { + $sid = trim($body['session_id'] ?? ''); + if (!$sid) Response::error('session_id required', 400); + $db->execute("DELETE FROM sessions WHERE id = ?", [$sid]); + Response::json(['success' => true]); + })(), + + $action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) { + $uid = (int)($body['user_id'] ?? 0); + if (!$uid) Response::error('user_id required', 400); + $count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]); + Response::json(['success' => true, 'data' => ['revoked' => $count]]); + })(), + + $action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) { + // Keep current session if provided + $keepId = $body['keep_session'] ?? null; + if ($keepId) { + $db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]); + } else { + $db->execute("DELETE FROM sessions"); + } + Response::json(['success' => true]); + })(), + + default => Response::error('Not found', 404), +}; diff --git a/panel/api/index.php b/panel/api/index.php index 1b7839e..7b84ec6 100644 --- a/panel/api/index.php +++ b/panel/api/index.php @@ -56,4 +56,38 @@ if (!file_exists($endpointFile)) { Response::error("Unknown endpoint: $endpoint", 404); } + + +// #28 Rate limiting — per-IP, per-endpoint bucket +(function() use ($endpoint) { + $db = DB::getInstance(); + $ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0"; + $now = time(); + $window = 60; + $limit = $endpoint === "auth" ? 10 : 120; + $bucket = $endpoint === "auth" ? "auth" : "api"; + try { + $row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]); + if ($row && ($now - (int)$row["window_start"]) < $window) { + $hits = (int)$row["hits"] + 1; + $db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]); + } else { + $hits = 1; + $db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]); + } + $reset = ($row ? (int)$row["window_start"] : $now) + $window; + $remaining = max(0, $limit - $hits); + header("X-RateLimit-Limit: {$limit}"); + header("X-RateLimit-Remaining: {$remaining}"); + header("X-RateLimit-Reset: {$reset}"); + if ($hits > $limit) { + http_response_code(429); + echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]); + exit; + } + } catch (Throwable $e) { + novacpx_log("warn", "rate limit error: " . $e->getMessage()); + } +})(); + require $endpointFile; diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index c16e457..504d9df 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -14,6 +14,7 @@