Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions _build/elements/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,46 @@
'xtype' => 'numberfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_store' => [
'value' => 'file',
'xtype' => 'textfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_storage_path' => [
'value' => '',
'xtype' => 'textfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_redis_dsn' => [
'value' => '',
'xtype' => 'textfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_redis_host' => [
'value' => '127.0.0.1',
'xtype' => 'textfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_redis_port' => [
'value' => 6379,
'xtype' => 'numberfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_redis_password' => [
'value' => '',
'xtype' => 'text-password',
'area' => 'ms3_api',
],
'ms3_rate_limit_redis_database' => [
'value' => 0,
'xtype' => 'numberfield',
'area' => 'ms3_api',
],
'ms3_rate_limit_memcached_servers' => [
'value' => '127.0.0.1:11211',
'xtype' => 'textfield',
'area' => 'ms3_api',
],

// Notifications
'ms3_telegram_bot_token' => [
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
},
"minimum-stability": "stable",
"prefer-stable": true,
"suggest": {
"ext-redis": "Shared rate-limit counters for multi-node Web API (ms3_rate_limit_store=redis)",
"ext-memcached": "Alternative shared rate-limit storage (ms3_rate_limit_store=memcached)"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.5"
Expand Down
4 changes: 3 additions & 1 deletion core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use MiniShop3\Middleware\CorsMiddleware;
use MiniShop3\Middleware\RateLimitMiddleware;
use MiniShop3\Middleware\ServiceCheckMiddleware;
use MiniShop3\Services\RateLimit\RateLimitStoreFactory;

$tokenMiddleware = new TokenMiddleware($modx);
$corsMiddleware = new CorsMiddleware([
Expand All @@ -44,7 +45,8 @@
]);
$rateLimitMiddleware = new RateLimitMiddleware(
$modx->getOption('ms3_rate_limit_max_attempts', null, 60),
$modx->getOption('ms3_rate_limit_decay_seconds', null, 60)
$modx->getOption('ms3_rate_limit_decay_seconds', null, 60),
RateLimitStoreFactory::fromModx($modx)
);
$serviceCheckMiddleware = new ServiceCheckMiddleware($modx);
$router->group('/api/v1', function($router) use ($modx, $tokenMiddleware) {
Expand Down
16 changes: 16 additions & 0 deletions core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@
$_lang['setting_ms3_rate_limit_max_attempts_desc'] = 'Maximum number of API requests per time period. Default is 60.';
$_lang['setting_ms3_rate_limit_decay_seconds'] = 'Rate limit time window (seconds)';
$_lang['setting_ms3_rate_limit_decay_seconds_desc'] = 'Time window in seconds for counting rate limit. Default is 60 seconds.';
$_lang['setting_ms3_rate_limit_store'] = 'Rate limit storage driver';
$_lang['setting_ms3_rate_limit_store_desc'] = 'Counter backend: file (default, single PHP node), redis or memcached for multi-node. MS3_RATE_LIMIT_STORE env overrides this setting.';
$_lang['setting_ms3_rate_limit_storage_path'] = 'Rate limit file storage path';
$_lang['setting_ms3_rate_limit_storage_path_desc'] = 'Directory for the file driver. Empty uses sys_get_temp_dir().';
$_lang['setting_ms3_rate_limit_redis_dsn'] = 'Rate limit Redis DSN';
$_lang['setting_ms3_rate_limit_redis_dsn_desc'] = 'Full DSN such as redis://:password@127.0.0.1:6379/0. When set, host/port/password/database below are ignored. Env: MS3_RATE_LIMIT_REDIS_DSN. Requires ext-redis.';
$_lang['setting_ms3_rate_limit_redis_host'] = 'Rate limit Redis host';
$_lang['setting_ms3_rate_limit_redis_host_desc'] = 'Redis host when DSN is not set.';
$_lang['setting_ms3_rate_limit_redis_port'] = 'Rate limit Redis port';
$_lang['setting_ms3_rate_limit_redis_port_desc'] = 'Redis port when DSN is not set.';
$_lang['setting_ms3_rate_limit_redis_password'] = 'Rate limit Redis password';
$_lang['setting_ms3_rate_limit_redis_password_desc'] = 'Redis password when DSN is not set.';
$_lang['setting_ms3_rate_limit_redis_database'] = 'Rate limit Redis database';
$_lang['setting_ms3_rate_limit_redis_database_desc'] = 'Redis database index (default 0) when DSN is not set.';
$_lang['setting_ms3_rate_limit_memcached_servers'] = 'Rate limit Memcached servers';
$_lang['setting_ms3_rate_limit_memcached_servers_desc'] = 'Comma-separated host:port list for the memcached driver. Env: MS3_RATE_LIMIT_MEMCACHED_SERVERS. Requires ext-memcached.';

// Notifications
$_lang['setting_ms3_telegram_bot_token'] = 'Telegram bot token';
Expand Down
16 changes: 16 additions & 0 deletions core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@
$_lang['setting_ms3_rate_limit_max_attempts_desc'] = 'Максимальное количество API запросов за период. По умолчанию 60.';
$_lang['setting_ms3_rate_limit_decay_seconds'] = 'Период лимита запросов (сек)';
$_lang['setting_ms3_rate_limit_decay_seconds_desc'] = 'Временное окно в секундах для подсчёта лимита запросов. По умолчанию 60 секунд.';
$_lang['setting_ms3_rate_limit_store'] = 'Хранилище rate limit';
$_lang['setting_ms3_rate_limit_store_desc'] = 'Драйвер счётчиков: file (по умолчанию, один PHP-узел), redis или memcached для multi-node. Переменная окружения MS3_RATE_LIMIT_STORE имеет приоритет.';
$_lang['setting_ms3_rate_limit_storage_path'] = 'Каталог file-хранилища rate limit';
$_lang['setting_ms3_rate_limit_storage_path_desc'] = 'Путь для file-драйвера. Пусто — sys_get_temp_dir().';
$_lang['setting_ms3_rate_limit_redis_dsn'] = 'Redis DSN для rate limit';
$_lang['setting_ms3_rate_limit_redis_dsn_desc'] = 'Полный DSN вида redis://:password@127.0.0.1:6379/0. Если задан, host/port/password/database ниже не используются. Env: MS3_RATE_LIMIT_REDIS_DSN. Требует ext-redis.';
$_lang['setting_ms3_rate_limit_redis_host'] = 'Redis host (rate limit)';
$_lang['setting_ms3_rate_limit_redis_host_desc'] = 'Хост Redis, если DSN не задан.';
$_lang['setting_ms3_rate_limit_redis_port'] = 'Redis port (rate limit)';
$_lang['setting_ms3_rate_limit_redis_port_desc'] = 'Порт Redis, если DSN не задан.';
$_lang['setting_ms3_rate_limit_redis_password'] = 'Redis password (rate limit)';
$_lang['setting_ms3_rate_limit_redis_password_desc'] = 'Пароль Redis, если DSN не задан.';
$_lang['setting_ms3_rate_limit_redis_database'] = 'Redis database (rate limit)';
$_lang['setting_ms3_rate_limit_redis_database_desc'] = 'Номер БД Redis (0 по умолчанию), если DSN не задан.';
$_lang['setting_ms3_rate_limit_memcached_servers'] = 'Memcached servers (rate limit)';
$_lang['setting_ms3_rate_limit_memcached_servers_desc'] = 'Список серверов host:port через запятую для memcached-драйвера. Env: MS3_RATE_LIMIT_MEMCACHED_SERVERS. Требует ext-memcached.';

// Notifications
$_lang['setting_ms3_telegram_bot_token'] = 'Токен Telegram бота';
Expand Down
127 changes: 17 additions & 110 deletions core/components/minishop3/src/Middleware/RateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
use MiniShop3\Router\Middleware\MiddlewareInterface;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\RateLimit\FileRateLimitStore;
use MiniShop3\Services\RateLimit\RateLimitStoreInterface;

/**
* Middleware for request rate limiting
*
* Protection against DDoS attacks and API abuse.
* Uses simple file-based cache mechanism.
*
* TODO: In production it's recommended to use Redis/Memcached for rate limiting
* Storage backend is pluggable (file by default, optional Redis/Memcached).
*/
class RateLimitMiddleware implements MiddlewareInterface
{
Expand All @@ -22,22 +22,21 @@ class RateLimitMiddleware implements MiddlewareInterface
/** @var int Time period in seconds */
private int $decaySeconds;

/** @var string Path to directory for storing rate limit data */
private string $storagePath;
private RateLimitStoreInterface $store;

/**
* @param int $maxAttempts Maximum number of requests (default 60)
* @param int $decaySeconds Time period in seconds (default 60 - 1 minute)
* @param string $storagePath Path to storage directory (default sys_get_temp_dir())
* @param RateLimitStoreInterface|null $store Storage backend (default: file in sys_get_temp_dir())
*/
public function __construct(
int $maxAttempts = 60,
int $decaySeconds = 60,
string $storagePath = ''
?RateLimitStoreInterface $store = null,
) {
$this->maxAttempts = $maxAttempts;
$this->decaySeconds = $decaySeconds;
$this->storagePath = !empty($storagePath) ? $storagePath : sys_get_temp_dir();
$this->store = $store ?? new FileRateLimitStore(sys_get_temp_dir(), $decaySeconds);
}

/**
Expand All @@ -50,131 +49,39 @@ public function handle(array $params)
{
$key = $this->resolveRequestKey();

$attempts = $this->getAttempts($key);
$resetTime = $this->getResetTime($key);
$state = $this->store->read($key);

// If time expired, reset counter
if (time() >= $resetTime) {
$this->resetAttempts($key);
$attempts = 0;
if (time() >= $state['reset_at']) {
$this->store->reset($key);
}

// Check limit
if ($attempts >= $this->maxAttempts) {
$retryAfter = $resetTime - time();
$state = $this->store->increment($key, $this->decaySeconds);

if ($state['attempts'] > $this->maxAttempts) {
$retryAfter = max(0, $state['reset_at'] - time());
header("Retry-After: $retryAfter");

return Response::error('ms3_err_rate_limit', HttpStatus::TOO_MANY_REQUESTS);
}

// Increment counter
$this->incrementAttempts($key);
$this->setRateLimitHeaders($state['attempts'], $state['reset_at']);

// Set rate limit headers
$this->setRateLimitHeaders($attempts + 1, $resetTime);

return null; // Continue execution
return null;
}

/**
* Get key for client identification
*
* @return string
*/
private function resolveRequestKey(): string
{
// Use combination of IP and token (if available)
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$token = $_SERVER['HTTP_MS3TOKEN'] ?? '';

return 'rate_limit:' . md5($ip . ':' . $token);
}

/**
* Get number of attempts
*
* @param string $key Key
* @return int
*/
private function getAttempts(string $key): int
{
$file = $this->getFilePath($key, 'attempts');

if (!file_exists($file)) {
return 0;
}

$content = file_get_contents($file);
return (int)$content;
}

/**
* Get counter reset time
*
* @param string $key Key
* @return int Unix timestamp
*/
private function getResetTime(string $key): int
{
$file = $this->getFilePath($key, 'reset');

if (!file_exists($file)) {
return time() + $this->decaySeconds;
}

$content = file_get_contents($file);
return (int)$content;
}

/**
* Increment attempts counter
*
* @param string $key Key
* @return void
*/
private function incrementAttempts(string $key): void
{
$attempts = $this->getAttempts($key) + 1;
$resetTime = $this->getResetTime($key);

// If this is first attempt in period, set reset time
if ($attempts === 1) {
$resetTime = time() + $this->decaySeconds;
}

file_put_contents($this->getFilePath($key, 'attempts'), $attempts);
file_put_contents($this->getFilePath($key, 'reset'), $resetTime);
}

/**
* Reset attempts counter
*
* @param string $key Key
* @return void
*/
private function resetAttempts(string $key): void
{
@unlink($this->getFilePath($key, 'attempts'));
@unlink($this->getFilePath($key, 'reset'));
}

/**
* Get file path for data storage
*
* @param string $key Key
* @param string $type Data type (attempts or reset)
* @return string
*/
private function getFilePath(string $key, string $type): string
{
return $this->storagePath . '/' . $key . '_' . $type . '.tmp';
}

/**
* Set rate limit headers
*
* @param int $attempts Current number of attempts
* @param int $resetTime Counter reset time
* @return void
*/
private function setRateLimitHeaders(int $attempts, int $resetTime): void
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace MiniShop3\Services\RateLimit;

/**
* File-based rate limit store for single-node deployments (default).
*/
class FileRateLimitStore implements RateLimitStoreInterface
{
public function __construct(
private string $storagePath,
private int $defaultWindowSeconds = 60,
) {
if ($this->storagePath === '') {
$this->storagePath = sys_get_temp_dir();
}
}

public function read(string $key): array
{
return [
'attempts' => $this->readAttempts($key),
'reset_at' => $this->readResetTime($key),
];
}

public function increment(string $key, int $windowSeconds): array
{
$attempts = $this->readAttempts($key) + 1;
$resetAt = $this->readResetTime($key);

if ($attempts === 1) {
$resetAt = time() + $windowSeconds;
}

$this->writeAttempts($key, $attempts);
$this->writeResetTime($key, $resetAt);

return [
'attempts' => $attempts,
'reset_at' => $resetAt,
];
}

public function reset(string $key): void
{
@unlink($this->filePath($key, 'attempts'));
@unlink($this->filePath($key, 'reset'));
}

private function readAttempts(string $key): int
{
$file = $this->filePath($key, 'attempts');
if (!is_file($file)) {
return 0;
}

return (int) file_get_contents($file);
}

private function readResetTime(string $key): int
{
$file = $this->filePath($key, 'reset');
if (!is_file($file)) {
return time() + $this->defaultWindowSeconds;
}

return (int) file_get_contents($file);
}

private function writeAttempts(string $key, int $attempts): void
{
file_put_contents($this->filePath($key, 'attempts'), (string) $attempts);
}

private function writeResetTime(string $key, int $resetAt): void
{
file_put_contents($this->filePath($key, 'reset'), (string) $resetAt);
}

private function filePath(string $key, string $type): string
{
return rtrim($this->storagePath, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. $key
. '_'
. $type
. '.tmp';
}
}
Loading