12 KiB
Сразу оговорюсь - я знаю про существование nginx naxsi
и mod_security
, речь про то, как вообще такое делается.
Под WAF я понимаю некое дополнение к веб-серверу, выполняющее одну или несколько следющих задач:
- блокировка вредоносных запросов
- подтверждение юзером "сомнительных" запросов
- ограничение частоты запросов по сложным критериям
Дополнительно можно собирать статистику.
Принцип работы всё тот же - nginx сначала пересылает копию запроса на какой-то внешний сервис и на основании ответа от этого сервиса - решает, пропускать ли исходный запрос или нет.
Механизм работы опсан в предыдущей статье, поэтому интересны прежде всего алгоритмы:
- выделение запросов одного пользователя,
- ограничения частоты запросов.
- определения "вредоносности" или "подозрительности" запроса, Нетрудно заметить, третья задача зависит от первой, а вторая и третья тесно связаны.
По порядку.
Выделение запросов одного пользователя
Первая и очевидная мысль, приходящая в голову - просто смотреть запросы с одного ip. Но тут есть множество тонкостей. Случаев, когда с одного адреса могут сидеть несколько пользователей - достаточно много: nat, vpn, tor, ipv6-to-4 брокер. И обратный случай - когда один юзер может сидеть с нескольких адресов: tor.
Что делать? На заголовки полагаться нельзя, там можно подделать абсолютно всё. Комбинация ip+заголовок, например User-Agent? Это ещё хуже, подделкой заголовка можно добиться, чтобы система определяла тебя как разных юзеров, даже не меняя свой ip.
Один из возможных вариантов - заставить каждого неопознанного клиента произвоить некую ресурсоёмкую операцию, после выполнения которой этому клиенту присваивается уникальный идентификатор, который тот будет предъявлять в дальнейшем как доказательство выполненной работы. Разумеется, его нужно защитить от подделки и ограничить срок годности.
Блок схема (исходник):
Путь "известного" юзера показан жирной линией, "неизвестного" - прерывистой. Операция (3) может быть к/либо вычислением на стороне пользователя, с сообщением результата. Или же - проверкой на робота, например следование по редиректам. сделанных средствами самого html.
Схема может изменяться в некоторых пределах, например (2) "N" может переходить не в (4), а в (3). Можно например, "неизвестных" клиентов сразу кидать на капчу, т.е. (1) "N" -> (4).
Думаю не нужно напоминать, что все операции, кроме (3) следует оптимизировать на минимальные затраты ресурсов и максимальное быстродействие. Если используется капча, её лучше нагенерить заранее и продумать механизм "карантина" на какое-то время для показанных, но не решённых.
Обратите внимание, путь по жирной линии выполняется в пределах одного обработчика.
А вот переходы к другим состояниям - требуют перехода на другие странички.
Помните, что я говорил про error_page
и поддержку редиректов?
Ограничение частоты запросов
Здесь может быть куча вариантов реализации.
Например самое простое и железобетонное решение: выделять временные слоты определённой длительности и считать запросы юзера в пределах текущего слота.
my $len = 5; # новый слот каждые 5 минут
my $time = time();
# вычисляем имя слота
my $slot = sprintf "req:%d:%d", $len, ($time - ($time % ($len * 60)));
# увеличиваем число запросов для юзера с $uuid
# и узнаём, сколько запросов он уже сделал в пределах данного слота
my $reqs = $redis->hincr($slot, $uuid => 1); # O(1)
$redis->expire($slot, 3600) if $reqs == 1;
# если $reqs >= $limit - блокируем запрос
Метод хорош тем, что крайне прост (1 запрос), хорошо масштабируется и может работать вообще без обслуживания. Основной недостаток - низкая "разрешающая способность", на границе временного слота можно превысить лимит до 2х раз.
Если нам нужна гарантия, что в каждый момент времени лимит не будет превышен, подход несколько другой. На каждого юзера заводится по персональному списку, туда пишется время запросов. Недостатки: стоимость выше, больший расход памяти.
Принцип действия такой: при частых запросах в начале очереди растёт число "недавних" запросов. Как только N-ый элемент оказывается "недавним", значит лимит превышен.
Вариант 2/а:
my ($time, $limit) = (5, 60); # время окна в минутах и количество запросов
my $now = time();
my $key = sprintf "user:%s", $uuid;
my $some = $redis->lindex($key, $limit - 1); # O(N)
my $next = $some + ($time * 60);
if ($now > $next) {
$redis->lpush($key, $now); # O(1)
# периодически подрезаем список, чтоб не разрастался сверх меры
$redis->ltrim($key, 0, $limit - 1); # O(N), где N - количество удалённых элементов
$redis->expire($key, 3600) if $some == 0;
} else {
# лимит превышен
}
Вариант 2/б, где "гарантированно дорогой" lindex() с O(N), заменяется на llen() + lindex(-1), т.е. 2 x O(1). Хотя в теории, lindex(N) для списка в котором меньше N элементов - тоже должен отрабатывать за O(1).
my ($time, $limit) = (5, 60); # время окна в минутах и количество запросов
my $now = time();
my $key = sprintf "user:%s", $uuid;
my $len = $redis->llen($key); # O(1)
if ($len < $limit) {
# первичное заполнение
$redis->lpush($key, $now); # O(1)
$redis->expire($key, 3600) if $len == 0;
return;
} else {
$redis->ltrim($key, 0, $limit - 1); # O(N), где N - количество удалённых элементов
my $last = $redis->lindex($key, -1); # O(1)
my $next = $last + ($time * 60);
if ($now >= $next) {
$redis->lpush($key, $now);
return;
}
}
# лимит превышен
Впринципе, всё это экономия на спичках. Значительно большего эффекта можно добиться, если считать не абстрактные "запросы", а прикинуть стоимость конкретного запроса в плане нагрузки и выделять пользователю "бюджет" на пользование.
Например, показ странички с картинками, где половина содержимого - статика, а остальное закешировано - это одно. Полнотекстовый поиск по сайту - это уже другое. А попытка авторизации на сайте - совсем даже третье.
"Вредоносность" и "подозрительность" запроса
Здесь сложно дать какие-то рекомендации, смотрите по ситуации. Можно анализировать заголовки (User-Agent/Referer/Accept/...), частоту запросов, их логическую взаимосвязь. Например, запрос к автодополнению с X-Requested-With - это с высокой вероятностью человек. Постоянная долбёжка поля поиска с интервалом в секунду - практически наверняка бот-дудосер. Монотонные запросы несвязанные друг с другом - поисковый бот. Пиковые всплески запросов с интервалом в несколько минут - юзер, который открывает несколько вкладок сразу, а потом сидит их читает.
Вобщем, на практке быстро научитесь на что нужно смотреть.
Ну и не стоит забывать про типовые запросы для поиска админок вордпресса, скуэля, гостевух и прочего говнокода на похапе. Клиенту, спалившемся на таком можно просто возвращать 404 на все запросы, чтоб больше не приходил.