web security · 10 мин

Rate limit в nginx: почему лимит по Cookie обходится и как проверить защиту по IP

nginxsecurityrate limit

Коротко

Иногда rate limit формально есть, но его можно обойти простой сменой Cookie. Это происходит, когда счётчик привязан к данным, которыми управляет клиент: PHPSESSID, GUEST_ID, произвольный session cookie и т.п.

В таком случае атакующий может отправлять много запросов к форме авторизации или регистрации, каждый раз меняя Cookie, и лимит будет воспринимать это как «нового клиента».

Базовый фикс: перенести первый слой ограничения на серверно-доверенный признак - например, реальный IP-адрес клиента на уровне nginx. Это не идеальная защита от всех атак, но хороший и быстрый внешний барьер, который можно проверить понятными командами.

Исходная проблема

Есть веб-приложение с endpoint’ами авторизации и регистрации:

/auth/?login=yes
/auth/?register=yes

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

Типовой плохой вариант выглядит так:

rate_limit_key = PHPSESSID
rate_limit_key = GUEST_ID
rate_limit_key = любой Cookie, который прислал клиент

Проблема в том, что Cookie - это не доверенный серверный идентификатор. Клиент может удалить, изменить или пересоздать его. Значит такой ключ нельзя использовать как единственную основу защиты от brute force или массовых запросов.

Что нужно получить

Минимальный закрывающий пакет:

  1. Ограничение частоты запросов на уровне nginx.
  2. Ключ лимита - реальный IP клиента, а не Cookie.
  3. Отдельные лимиты для логина и регистрации.
  4. HTTP-ответ 429 Too Many Requests при превышении.
  5. Отдельный лог для событий 429, чтобы потом было проще показать факт срабатывания и анализировать атаки.
  6. Проверка, что смена Cookie больше не сбрасывает лимит.

Важное условие: реальный IP

Перед настройкой нужно понять, что находится перед nginx.

Если nginx принимает трафик напрямую из интернета, то обычно можно использовать:

$binary_remote_addr

Если перед nginx есть CDN, WAF, балансировщик или другой reverse proxy, сначала нужно корректно настроить доверенные источники real_ip:

set_real_ip_from 192.0.2.10;      # пример: IP доверенного reverse proxy
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Нельзя слепо доверять X-Forwarded-For от интернета. Иначе атакующий сможет просто подставлять разные IP в заголовок и обходить лимит так же легко, как раньше обходил его сменой Cookie.

Пример настройки в nginx

Ниже пример реализации. Его нужно адаптировать под конкретную схему upstream/backend.

1. Ключи лимитов и зоны

Внутри блока http { ... }:

# Empty key is not accounted by limit_req_zone.
# For /auth/?login=yes key becomes client IP.
map $arg_login $auth_login_rl_key {
    default "";
    yes     $binary_remote_addr;
}

# For /auth/?register=yes key becomes client IP.
map $arg_register $auth_register_rl_key {
    default "";
    yes     $binary_remote_addr;
}

# Log only 429 responses into a separate file.
map $status $log_429 {
    default 0;
    429     1;
}

# Login brute force protection.
limit_req_zone $auth_login_rl_key zone=auth_login_ip:10m rate=10r/m;

# Registration abuse protection.
limit_req_zone $auth_register_rl_key zone=auth_register_ip:10m rate=5r/m;

# Return 429 instead of nginx default 503.
limit_req_status 429;

# Put rate-limit events into nginx error log with warn level.
limit_req_log_level warn;

Что здесь важно:

  • лимит для логина и регистрации разделён;
  • для остальных запросов к /auth/ ключ пустой, они не попадают в эти зоны;
  • Cookie вообще не участвуют в ключе;
  • при превышении возвращается 429, а не стандартный 503.

2. Отдельный JSON-лог для 429

Внутри http { ... } можно добавить отдельный формат:

log_format rate_limit_json escape=json
'{'
'"ts":"$time_iso8601",'
'"status":$status,'
'"remote_addr":"$remote_addr",'
'"realip_remote_addr":"$realip_remote_addr",'
'"host":"$host",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"args":"$args",'
'"referer":"$http_referer",'
'"user_agent":"$http_user_agent",'
'"request_time":$request_time'
'}';

И рядом с основным access_log:

access_log /var/log/nginx/access.log main;
access_log /var/log/nginx/rate_limit_429.log rate_limit_json if=$log_429;

Так события превышения лимита будут попадать в отдельный файл:

/var/log/nginx/rate_limit_429.log

3. Применение лимитов к location

В server/location для auth:

location ^~ /auth/ {
    # /auth/?login=yes
    limit_req zone=auth_login_ip burst=5 nodelay;

    # /auth/?register=yes
    limit_req zone=auth_register_ip burst=3 nodelay;

    proxy_pass http://backend;
}

burst задаёт короткий запас для резких пачек запросов. Например, при rate=10r/m и burst=5 первые несколько быстрых запросов могут пройти, а затем nginx начнёт отдавать 429. Это нормальное поведение.

Проверка

После изменения конфига:

nginx -t
systemctl reload nginx

Проверка логина

for i in $(seq 1 20); do
  curl -k -s -o /dev/null -w "%{http_code}\n" \
    "https://example.org/auth/?login=yes"
done

Ожидаемо: сначала несколько обычных ответов приложения (200, 302, иногда 403 - зависит от backend), затем 429.

for i in $(seq 1 20); do
  curl -k -s -o /dev/null -w "%{http_code}\n" \
    -H "Cookie: GUEST_ID=$RANDOM; PHPSESSID=$RANDOM" \
    "https://example.org/auth/?login=yes"
done

Если фикс сработал, смена Cookie не должна сбрасывать лимит. После превышения снова должны появиться 429.

Проверка регистрации

for i in $(seq 1 20); do
  curl -k -s -o /dev/null -w "%{http_code}\n" \
    "https://example.org/auth/?register=yes"
done

И отдельно со сменой Cookie:

for i in $(seq 1 20); do
  curl -k -s -o /dev/null -w "%{http_code}\n" \
    -H "Cookie: GUEST_ID=$RANDOM; PHPSESSID=$RANDOM" \
    "https://example.org/auth/?register=yes"
done

Проверка отдельного лога

tail -n 20 /var/log/nginx/rate_limit_429.log

Пример строки:

{"ts":"2026-06-10T11:18:32+03:00","status":429,"remote_addr":"203.0.113.10","realip_remote_addr":"203.0.113.10","host":"example.org","method":"GET","uri":"/auth/?login=yes","args":"login=yes","referer":"","user_agent":"curl/8.5.0","request_time":0.000}

request_time: 0.000 - хороший признак: nginx отдал отказ до обращения к backend, не нагружая приложение.

Что записать в отчёт по исправлению

Короткая формулировка:

Механизм ограничения частоты запросов перенесён на уровень nginx и использует
IP-адрес источника запроса как серверно-доверенный идентификатор клиента.
Для endpoint'ов /auth/?login=yes и /auth/?register=yes настроены отдельные
limit_req зоны. При превышении лимита nginx возвращает HTTP 429 Too Many
Requests и пишет событие в отдельный лог /var/log/nginx/rate_limit_429.log.

Проверка показала, что повторные запросы к /auth/?login=yes и
/auth/?register=yes получают HTTP 429 после превышения лимита. Дополнительная
проверка с подменой Cookie GUEST_ID и PHPSESSID подтвердила, что изменение
Cookie не влияет на счётчик rate limit и не позволяет обойти ограничение.

Не забыть про logrotate

Если завели отдельный лог, его нужно ротировать. Иначе файл будет расти.

Пример /etc/logrotate.d/nginx-rate-limit-429:

/var/log/nginx/rate_limit_429.log {
    daily
    rotate 30
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    create 0640 www-data adm
    postrotate
        [ -s /run/nginx.pid ] && kill -USR1 `cat /run/nginx.pid`
    endscript
}

Владельца www-data adm нужно заменить, если nginx в конкретной системе работает от другого пользователя/группы.

Проверка:

logrotate -d /etc/logrotate.d/nginx-rate-limit-429

Ограничения такого подхода

IP-rate-limit - это хороший первый слой, но не универсальная защита.

Что остаётся:

  • пользователи за NAT/офисным или мобильным IP могут делить один лимит;
  • распределённая атака с множества IP будет проходить лучше;
  • nginx не умеет удобно и безопасно лимитировать по логину/email из POST body;
  • для защиты конкретного аккаунта нужен app-level limit по логину/email и паре IP + login;
  • после порога может понадобиться CAPTCHA, progressive delay или временная блокировка.

Более сильная production-схема обычно выглядит так:

  1. nginx limit_req по real IP как внешний слой;
  2. app-level rate limit по логину/email и IP+login;
  3. Redis или другое атомарное хранилище счётчиков;
  4. отдельные события безопасности в логах;
  5. fail2ban/CrowdSec или WAF как дополнительный слой;
  6. нейтральные ответы приложения, чтобы не усиливать user enumeration.

Главный вывод

Rate limit нужно проверять не только на наличие, но и на устойчивость к обходу.

Если счётчик завязан на Cookie, такой лимит можно сбросить клиентской стороной. Если он завязан на доверенный real IP и проверен повторным сценарием со сменой Cookie, это уже гораздо более надёжный первый слой защиты.

Для меня главный практический критерий здесь простой: после изменения GUEST_ID и PHPSESSID серия запросов всё равно должна приходить к 429, а событие должно быть видно в отдельном логе.