Без ограничения количества запросов (rate limiting) автоматизированный злоумышленник может отправить куда больше попыток входа, чем ваше приложение в принципе должно принимать. Вот как это остановить на чистом PHP и в Laravel.
Это тринадцатая статья из цикла о безопасности PHP и Laravel приложений.
На данный момент мы разборaли:

  • Обнаружение попыток SQL-инъекций в логах PHP
  • Почему URL-кодирование ослепляет большинство проверок безопасности в PHP
  • Проблему «бомбы декодирования» при неограниченном декодировании URL
  • Почему параметризованные запросы — единственное реальное исправление для SQL-инъекций
  • Защиту от XSS в Laravel и почему {!! !!}  — это грань между безопасностью и взломом
  • Как атакующие исследуют ваше Laravel-приложение перед использованием уязвимостей
  • Безопасность загрузки файлов — файл, который оказывается не тем, чем притворяется
  • Path traversal в PHP — как ../  позволяет выйти за пределы вашего приложения
  • Внедрение команд в PHP — когда exec()  становится вектором атаки
  • Нарушение контроля доступа в Laravel — почему статуса авторизованного пользователя недостаточно
  • Секреты в Laravel — почему .env  — это только начало
  • Безопасность сессий в PHP — в чём ошибается большинство разработчиков

Каждая статья в этой серии следует одному и тому же принципу: поймите суть атаки перед тем, как пытаться её предотвратить.

Атаки методом перебора (brute force) не отличаются изысканностью. Они не требуют эксплуатации уязвимостей в вашем коде. Всё, что им нужно — чтобы ваше приложение принимало неограниченное количество попыток входа, чем грешит большинство PHP-приложений.

Что на самом деле представляет собой Brute Force

Атака методом перебора — это когда злоумышленник подбирает множество паролей к форме входа в надежде, что один из них подойдёт. Современные брутфорс-атаки полностью автоматизированы. Скрипт отправляет запросы на авторизацию с максимальной скоростью, которую способна обработать ваша инфраструктура.

Существует три распространённых разновидности:

Простой перебор (Simple brute force) перебирает все возможные комбинации символов, пока одна из них не сработает. Это долго, но исчерпывающе. Применимо только против очень слабых паролей.

Атака по словарю (Dictionary attacks) проверяет список популярных паролей, таких как password123 , qwerty , admin , letmein . Быстрый и эффективный метод, поскольку большинство пользователей выбирают предсказуемые пароли.

Credential stuffing использует связки логинов и паролей, утекавшие с других взломанных сайтов. Если пользователь повторно использовал свой пароль от ранее скомпрометированного сервиса, злоумышленник получает доступ мгновенно, без необходимости что-либо угадывать. Это самый эффективный метод современной атаки на формы входа, так как он эксплуатирует человеческий фактор, а не уязвимости приложения.

Все три метода объединяет одна общая черта: им требуется огромное количество запросов. Ограничение частоты запросов (rate limiting) существенно усложняет жизнь злоумышленникам, ограничивая допустимое число попыток за заданный интервал времени.

Rate Limiting на чистом PHP

В PHP нет встроенных механизмов rate limiting. Вы реализуете его самостоятельно, используя то или иное хранилище для отслеживания попыток.

Rate limiting на основе сессий — исключительно в демонстрационных целях:

session_start();
function checkRateLimit(int $maxAttempts, int $windowSeconds): bool
{
    $key = 'login_attempts';
    $windowKey = 'login_window_start';
    $now = time();

    if (!isset($_SESSION[$windowKey]) ||
        ($now - $_SESSION[$windowKey]) > $windowSeconds) {
        $_SESSION[$windowKey] = $now;
        $_SESSION[$key] = 0;
    }

 $_SESSION[$key]++;

 return $_SESSION[$key] <= $maxAttempts;
}

if (!checkRateLimit(5, 60)) {
    http_response_code(429);
    header('Retry-After: 60');
    die('Too many attempts. Please try again in 60 seconds.');
}

Rate limiting на сессиях нельзя рассматривать как полноценную защиту от брутфорса. Атакующий может создавать или сбрасывать сессии раз за разом, обнуляя счётчик при каждом запросе. Используйте этот способ исключительно для простой демонстрации или в крайнем случае, но никак не в качестве реальной защиты. Сессии привязаны к конкретному браузеру, а автоматизированные утилиты, не сохраняющие cookie сессий, легко обходят такое ограничение.

Rate limiting на базе базы данных — надёжно при умеренном трафике:

// Required table
// CREATE TABLE login_attempts (
//     id INT AUTO_INCREMENT PRIMARY KEY,
//     identifier VARCHAR(255) NOT NULL,
//     attempted_at INT NOT NULL,
//     INDEX idx_identifier_time (identifier, attempted_at)
// );

function checkRateLimitDb(
    PDO $pdo,
    string $identifier,
    int $maxAttempts,
    int $windowSeconds
): bool {
    $now = time();
    $windowStart = $now - $windowSeconds;
    $stmt = $pdo->prepare('
        SELECT COUNT(*)
        FROM login_attempts
        WHERE identifier = ?
        AND attempted_at > ?
    ');
    $stmt->execute([$identifier, $windowStart]);
    $count = (int) $stmt->fetchColumn();
    if ($count >= $maxAttempts) {
        return false;
    }
    $stmt = $pdo->prepare('
        INSERT INTO login_attempts (identifier, attempted_at)
        VALUES (?, ?)
    ');
    $stmt->execute([$identifier, $now]);
    return true;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!checkRateLimitDb($pdo, 'login:ip:' . $ip, 5, 60)) {
    http_response_code(429);
    header('Retry-After: 60');
    die('Too many login attempts. Please try again in 60 seconds.');
}

Rate limiting на базе Redis — стандарт для продакшена:

Redis отлично подходит для реализации rate limiting в продакшене, особенно когда вам нужны атомарные счётчики и общее состояние между несколькими нодами приложения:

function checkRateLimitRedis(
    Redis $redis,
    string $identifier,
    int $maxAttempts,
    int $windowSeconds
): bool {
    $key = 'rate_limit:' . $identifier;

$attempts = $redis->incr($key);
    if ($attempts === 1) {
        $redis->expire($key, $windowSeconds);
    }
    return $attempts <= $maxAttempts;
}
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$email = strtolower(trim($_POST['email'] ?? ''));
$ipAllowed = checkRateLimitRedis($redis, 'login:ip:' . $ip, 10, 60);
$emailAllowed = checkRateLimitRedis($redis, 'login:email:' . $email, 5, 300);
if (!$ipAllowed || !$emailAllowed) {
    http_response_code(429);
    header('Retry-After: 60');
    die('Too many attempts. Please try again later.');
}

Важное примечание об атомарности: последовательность INCR , за которой следует EXPIRE , не является единой атомарной операцией. В редких случаях сбоя значение ключа может увеличиться без установки времени жизни (TTL), из-за чего ключ останется в базе навсегда. Для критически важных продакшен-решений стоит использовать транзакции Redis или Lua-скрипт, чтобы сделать операцию полностью атомарной.

Rate Limiting по нескольким факторам

Ограничение только по IP-адресу имеет уязвимое место: злоумышленники с ботнетами распределяют запросы по тысячам IP-адресов, делая лишь по нескольку попыток с каждого. Одновременное ограничение по нескольким факторам закрывает эту брешь:

По IP-адресу — останавливает автоматизированные атаки из единого источника.

По имени пользователя или email — не даёт менять IP-адреса для атаки на один конкретный аккаунт. Лимит на уровне аккаунта сработает даже при запросах с разных IP.

По комбинации IP и email — точечное ограничение для конкретного атакующего, нацелившегося на конкретную учётную запись.

$ipAllowed = checkRateLimitRedis($redis, 'login:ip:' . $ip, 10, 60);
$emailAllowed = checkRateLimitRedis($redis, 'login:email:' . $email, 5, 300);
$combinedAllowed = checkRateLimitRedis($redis, 'login:combined:' . $ip . ':' . $email, 3, 60);

if (!$ipAllowed || !$emailAllowed || !$combinedAllowed) {
    http_response_code(429);
    header('Retry-After: 60');
    die('Too many attempts. Please try again later.');
}

Прогрессивные задержки (Progressive Delays)

Вместо жесткой блокировки при превышении порога, прогрессивные задержки увеличивают время ожидания после каждой неудачной попытки:

$delays = [0, 0, 0, 2, 5, 10, 30, 60];

$attempts = (int) ($redis->get('login:attempts:' . $ip) ?: 0);
$delay = $delays[min($attempts, count($delays) - 1)];
if ($delay > 0) {
    sleep($delay);
}

Прогрессивные задержки более лояльны к обычным пользователям, опечатавшимся в пароле: они сталкиваются лишь с небольшой паузой, а не с мгновенным баном. В то же время автоматизированные атаки колоссально замедляются, так как каждая попытка начинает стоить злоумышленнику времени.

Rate Limiting в Laravel

В Laravel встроен собственный механизм ограничения запросов, закрывающий большинство задач, которые вам пришлось бы писать вручную.

Middleware throttle — самый простой подход:

// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:5,1'); // 5 attempts per 1 minute per IP

Это необходимый минимум. Для эндпоинта авторизации в продакшене требуется более гибкий контроль.

Именованные ограничениели (Rate Limiters) — правильный подход для боевого окружения:

Конкретное место объявления ограничений зависит от версии Laravel (в свежих релизах конфигурация сместилась), однако приведенный ниже API остаётся неизменным во всех версиях:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->ip()),
        Limit::perMinutes(5, 10)->by($request->input('email')),
    ];
});
RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(60)->by($request->user()->id)
        : Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('sensitive', function (Request $request) {
    return [
        Limit::perMinute(3)->by($request->ip()),
        Limit::perHour(10)->by($request->ip()),
    ];
});// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');
Route::post('/password/reset', [PasswordController::class, 'send'])
    ->middleware('throttle:sensitive');
// routes/api.php
Route::middleware('throttle:api')->group(function () {
    Route::get('/user', [UserController::class, 'show']);
    Route::apiResource('invoices', InvoiceController::class);
});

Ручное управление rate limiting в контроллерах — максимальный контроль:

use Illuminate\Support\Facades\RateLimiter;

public function login(Request $request)
{
    $key = 'login:' . $request->ip() . ':' . $request->input('email');
    if (RateLimiter::tooManyAttempts($key, 5)) {
        $seconds = RateLimiter::availableIn($key);
        return response()->json([
            'message' => "Too many attempts. Try again in {$seconds} seconds."
        ], 429);
    }
    if (!Auth::attempt($request->only('email', 'password'))) {
        RateLimiter::hit($key, 60);
        return response()->json(['message' => 'Invalid credentials.'], 401);
    }
    RateLimiter::clear($key);
    $request->session()->regenerate();
    return response()->json(['message' => 'Authenticated.']);
}

Четыре ключевых метода, которые нужно знать:

  • RateLimiter::hit($key, $decay)  фиксирует попытку и задаёт время сброса в секундах
  • RateLimiter::tooManyAttempts($key, $maxAttempts)  проверяет, не превышен ли лимит
  • RateLimiter::clear($key)  сбрасывает счётчик. Вызывайте его после успешного входа, чтобы легитимный пользователь, ошибшийся при вводе пароля, мог начать с чистого листа
  • RateLimiter::availableIn($key)  возвращает количество секунд до сброса лимита — полезно для понятных пользователю сообщений об ошибках

При превышении ограничения заголовок Retry-After  сообщает клиенту, через сколько секунд можно повторить запрос. Если ваше API отдает заголовки вроде X-RateLimit-Limit  и X-RateLimit-Remaining , клиенты смогут отслеживать свой текущий лимит (конкретный набор заголовков зависит от версии и конфигурации Laravel).

Использование Redis в качестве драйвера Rate Limiter в продакшене:

Механизм Rate Limiter в Laravel полагается на драйвер кэша. Для боевого сервера укажите Redis в качестве кэш-драйвера:

# .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

С Redis в качестве драйвера кэша rate limiting в Laravel работает атомарно, быстро и автоматически распределяется между несколькими серверами приложения.

Что именно стоит ограничивать

Не только эндпоинты авторизации. Любой URL, которым можно злоупотребить, выигрывает от внедрения rate limiting:

Ограничивать всегда — строгие лимиты:

  • Формы входа в систему
  • Запросы на сброс пароля
  • Формы регистрации
  • Повторную отправку подтверждения email
  • Ввод OTP и кодов двухфакторной аутентификации (2FA)
  • Подтверждение удаления аккаунта

Ограничивать умеренно:

  • API-эндпоинты для авторизованных пользователей
  • Эндпоинты поиска
  • Загрузку файлов

Ограничивать лояльно (мягкие лимиты):

  • Публичные API-эндпоинты
  • Формы обратной связи
  • Отправку комментариев

Реакция на превышение лимитов

То, как именно отвечает ваше приложение, столь же важно, как и сам факт наличия защиты:

// Wrong — reveals too much
return response()->json([
    'error' => 'You have made 5 failed login attempts for user@example.com'
], 429);

// Correct - generic with retry timing
return response()->json([
    'message' => 'Too many attempts. Please try again later.',
    'retry_after' => $seconds
], 429);

Никогда не раскрывайте, какой именно фактор вызвал сработку rate limit. Не сообщайте количество сделанных попыток и факт существования учетной записи. Обезличенные ответы защищают от перечисления (энумерации) существующих аккаунтов на основе реакций системы.

Чек-лист по Rate Limiting

Для чистого PHP:

  • Используйте Redis в продакшене вместо сессий
  • Настраивайте лимиты отдельно по IP-адресу и по логину
  • Учитывайте, что связка INCR и EXPIRE не атомарна — используйте транзакции для критичных мест
  • Добавляйте заголовок Retry-After  к каждому 429-му ответу
  • Автоматически очищайте устаревшие данные о попытках через TTL в Redis
  • Внедряйте прогрессивные задержки для удобства реальных пользователей
  • Логируйте нарушения лимитов для последующего анализа безопасности

Для Laravel:

  • Применяйте именованные ограничения (Rate Limiters) для сложных правил
  • Комбинируйте ограничения по IP и email на эндпоинтах авторизации
  • Используйте RateLimiter::clear()  для сброса счетчика при успешном входе
  • Задавайте CACHE_DRIVER=redis  на боевом сервере
  • Защищайте эндпоинты сброса паролей, регистрации, OTP и 2FA
  • Не раскрывайте детали сработавшего лимита в текстах ошибок
  • Уточняйте правильное место конфигурирования Rate Limiter под вашу версию Laravel