DJ-Andrey-sXe Logo

DJ-Andrey-sXe

Музыка, программирование, юмор | djtal logиг

Защита веб-приложений на Perl

Вторая версия статьи (включает 18 пунктов из первой версии), обновлена: 5 июня 2012.

Эту статью можно не только читать, но и слушать.

Сборник практических правил и советов с примерами и разъяснениями. Главным образом речь идет о языке Perl и сервере Apache, но многое из сказанного справедливо для других подобных средств разработки.

Приведённые примеры кода не претендуют на универсальность и законченность, однако, они все проверены и работают на свежих версиях Perl. Почти все пункты уже применялись в реальных скриптах, и я чуть ли не каждую неделю с удовольствием читаю отчеты об успешно отраженных нападениях на сервера.

40 правил

1. Не стройте свою защиту только лишь на средствах HTML. Таких как input типа hidden, задание параметров вроде readonly, disabled, maxlength и т. д. Всё это легко обходится построением запроса самостоятельно.

Есть несколько инструментов для Firefox. Например, Firebug, Tamper Data, Hack Bar. Посмотрите на них и больше никогда не доверяйте только лишь одному HTML.

В Firebug, например, скрытые элементы редактируются на ура. Аттрибуты maxlength, disabled, readonly снимаются очень просто. HTML-код, сгенерированный на JavaScript просматривается полноценно, а изменения отражаются немедленно.

Кстати, Firebug в первую очередь — прекрасное средство для отладки.

Все верно выставленные параметры и JavaScript-обработчики (не в качестве защиты) нужны для нормального пользователя, чтобы он понял, сколько он может вводить, где нельзя нажать и т. п. А ненормальный пользователь всё равно построить запрос руками. Собственно, остальная часть статьи во многих местах рассказывает вам, как защититься от построенного самостоятельно поддельного запроса.

2. Не доверяйте тому, что пришло от пользователя в Cookies. Только ленивый взломщик не станет пробовать их поменять (например, в Opera, до куки можно добраться в 3 клика). Представьте, что там вполне могут оказаться кавычки, апострофы, вообще любая текстовая каша, длинная строка (которая обязательно не влезет в поле базы), огромное число, 0, отрицательное число, или просто пустота. Всегда приравнивайте опасность, исходящую от подделки Cookies к опасности от подделки параметров GET, POST и прочих HTTP-запросов.

3. В первую очередь надо написать проверку на стороне сервера, а уже только потом на клиенте. В случае нехватки времени, клиентскую проверку можно отложить до следующей версии, но вас уже не сломают. Не надейтесь на OnSubmit, OnKeyPress и прочий JavaScript. Он отключается. Отладка не просто доступна, она кое-где даже удобна. JavaScript — только как средство защиты от ошибок нормальных пользователей и чтобы не мучить сервер обработкой заведомо неверно заполненных форм.

4. Не важно, насколько крут ваш любимый обфускатор, JavaScript вам от исследования не оградить. Загляните сюда и перестаньте надеяться на защиту обфускатором. Если вы считаете, что цепочка кодирований и шифрований спасет вас, то я напомню вам, что рано или поздно код должен быть выполнен браузером в нормальном виде, и когда-нибудь таковым он все-таки попадает в eval (параметр eval — строка интерпретируется и выполняется как код JavaScript). Я ради прикола иногда не отказываю себе в удовольствии распотрошить очередной вирус на JS, так вот даже их создатели ничего не могут поделать с сокрытием алгоритма.

5. Если форма отправляется, к примеру, методом POST, сервер должен отказаться принимать любой другой метод. Исключением из правил может стать лишь скачивание файла: там нужен как GET, так и HEAD.

Как вам перспектива массовой накрутки счетчика злоумышленником, либо добавление записей флудером при помощи метода HEAD? Копеечный трафик, а какой эффект!

6. Любой param или cookie (в терминах модуля CGI.pm) НУЖНО проверить или отфильтровать. Исключение — это данные, которые не будут фрагментом SQL-запроса, регулярного выражения, вываливаться в HTML, выполняться на сервере или сохраняться для последующей передачи клиенту.

7. Данные от пользователя без фильтрации нельзя использовать в регулярных выражениях.

Пример неправильного кода:

#!/usr/bin/perl
$a = 'text1 [] text2';
$b = '[]';
if ( $a =~ /$b/ )
{
  print '[] exists in $a\n'
}

Чтобы избежать обработки «регулярных» метасимволов, нужно писать переменные между \Q и \E:

$a = param 'a';
if ( $text =~ /\Q$a\E/ )
{
  # ...
}

Есть функция под названием quotemeta. Она подготавливает строку символов к безопасному помещению в регулярное выражение. Все не алфавитно-цифровые символы будут проэкранированы обратным слэшэм.

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

  • Не может быть двух пользователей с совпадающими логинами, email или одинаковыми паспортными данными.
  • Не должно появиться двух пунктов меню или в списке на одном уровне с полностью совпадающим названием.
  • Не может существовать двух предметов под названием «Физическая география» с разными id.

И так далее. Можно привести массу примеров, главное — вспоминать об этом принципе при проектировании всего нового. В борьбе с неуникальностью помогут уникальное индексирование или ручная проверка select-ом перед вставкой.

Последнее время стало модно проверять данные с помощью Ajax прямо во время заполнения форм.

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

9. Создайте альтернативные функции считывания данных cookie или param. Пользуйтесь только ими там, где наличие параметра обязательно и тип данных не меняется. Они должны работать как обычные, но если только формат данных не соответствуют ожидаемому, программа прекратит выполнение обработки запроса. Для начала любому серьёзному проекту точно понадобятся функции валидации дат, чисел, диапазонов чисел и нахождения переданной строки в заданном списке строк или поле таблицы базы данных.

Примеры таких функций:

# целое > 0
sub param_gt0($)
{
  my $p = shift;
  if ( param($p) !~ m/^\d+$/ or param($p) == 0 )
  {
    # Реакция на несоответствие
  }
  return param $p;
}
# целое >= 0
sub param_gteq0($)
{
  my $p = shift;
  unless ( defined param $p )
  {
    # Реакция на неопределённое значение,
    # то есть только имя параметра без значения. Пример: script.pl?a=
  }
  if ( param($p) !~ m/^\d+$/ or param($p) < 0 )
  {
    # Реакция на несоответствие
  }
  return param $p;
}
# целое в пределах диапазона
sub param_range($$$)
{
  my ($p,$min,$max) = (shift,shift,shift);
  unless ( defined param $p )
  {
    # Реакция на неопределённое значение,
  }
  if ( param($p) !~ m/^\d+$/ or param($p) < $min or param($p) > $max )
  {
    # Реакция на несоответствие
  }
  return param $p;
}
# дата в формате ДД.ММ.ГГГГ
sub param_date($)
{
  my $p = shift;
  if ( param($p) !~ m/^[0-3]?[0-9]\.[0-1]?[0-9]\.[0-9][0-9][0-9][0-9]$/ )
  {
    # Реакция на несоответствие и подсказка правильного формата с примером
  }
  return param $p;
}

И тому подобные.

Примеры использования:

my $date   = param_date  'birth_date';     # дата
my $age    = param_gt0   'age';            # целое > 0
my $index  = param_gteq0 'index';          # целое >= 0
my $copies = param_range 'copies', 2, 100; # диапазон

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

Вот пара простых примеров:

  • Если вы спрашиваете у пользователя, когда он создал свой сайт, не позволяйте ему указать 1634 год (тогда интернета не было) или дату в будущем.
  • Или вы спрашиваете возраст пользователя. Не нужно позволять ему указывать 300 лет. К сожалению, столько не живут. Правда в этом конкретном случае лучше быть оптимистом, да и деваться некуда — нужно ставить с запасом. Зато представьте себе, какая радость, когда какой-то пользователь пожалуется на ограничение в 150 лет :)

10. В формах ввода данных в базу проставляйте параметр maxlength для текстовых полей, равный размеру поля в базе. Не ограничивайтесь этим, всегда обрезайте текст и на сервере.

HTML:

<input type="text" name="surname" maxlength="64">

Perl:

# неправильно
$surname = param 'surname';

# правильно
$surname = substr(param('surname'), 0, 64);

Можно сделать обёртку:

sub paramn
{
  return substr(param(shift), 0, shift);
}
# и использовать её:
$a = paramn('a', 15);

11. Используйте метод quote интерфейса DBI, если текст, введенный пользователем, нужно использовать в SQL-запросах. Либо биндинг параметров, что обязательно для BLOB-полей и полезно для простого текста. Этим вы будете защищены от SQL-Injection атак.

Примеры с биндингом:

$query = $db->prepare 'select a,b from test where c = ?';
$query->execute($c);
$query = $db->prepare 'insert into test (a,b,c) values (?,?,?)';
$query->execute($a,$b,$c);

Пример с quote:

$str = $db->quote q[что угодно, и даже разные кавычки: "test" и 'тест'...];
$query = $db->prepare "insert into test (a) values ($str)";
$query->execute();

Смешивать quote и биндинг нельзя!

12. XSS может быть не менее опасен, чем дыра другого класса. Только представьте себе, что вы залогинились и просматриваете HTML, в который злоумышленник встроил зловредный JavaScript, переправляющий вас на URL, который для вашей системы, к примеру, является командой грохнуть информацию или отправить ее на сторону.

Пример. Представьте себе, что вашу форму заполнили вот так:

ФИО


А если там будет что-то вроде этого. Как вам?

ФИО


Защищайтесь даже от не вполне очевидных опасностей, скажем, от поля User-Agent запроса (который часто светится в логах или статистике, что, как правило, дело админское, а там XSS еще более опасен).

13. Позволяйте вставлять HTML только там, где это действительно необходимо. Лучше дайте пользователю редактор вроде FCKeditor, TinyMCE, HTMLArea, NicEdit и тому подобные. В остальных случаях режьте все лишнее. Отправной точкой может служить такая резалка:

s/&/&amp;/go;
s/</&lt;/go;
s/</&gt;/go;
s/'/&apos;/go;
s/"/&quot;/go;

Обратите внимание на готовые модули для чистки HTML:

HTTML::Scrubber
HTML::Detoxifier
HTML::TagFilter

14. Не провоцируйте взломщика к дальнейшим действиям — не говорите, в какой строке и в каком файле произошла ошибка. Не цитируйте сообщений интерпретатора, не показывайте SQL-запросы. Используйте mod_rewrite, это не только красивые URL’ы, это дополнительное сокрытие деталей реализации. Лучше тихо сложите подробности ситуации в лог, потом сами посмотрите, а пользователя не стоит грузить непонятными дампами. Скажите ему, что произошла ошибка, чтобы он проверил вводимые данные, попробовал перезалогиниться, пусть он, наконец, обратится в суппорт.

15. Не доверяйте никому: неавторизованному проходящему мимо серферу, пользователю и даже администратору. Почему? Любая функция, даже редкоиспользуемая или которая только для администратора должна быть крепко защищена не хуже других. Представьте, админ тоже человек, он может забыть разлогиниться, или он может просто оказаться недобросовестным и сам попытаться искать какой-нибудь SQL-Injection в админке, рассчитывая на то, что, ну, уж админу-то вроде доверять должны…

16. В ряде случаев вам не удастся полноценно восстановить картину действий пользователей по логам Apache. Ведите собственный журнал безопасности. Разумеется, для экономии хранить в нём следует лишь критические действия пользователей, такие как регистрация, вход/выход, смена пароля, неудачные попытки входа, поведение, похожее на перебор паролей или поиск уязвимостей, IP-адреса (и Proxy, если есть), User-Agent’ы, дату/время и тому подобное. Это позволит намного удобнее анализировать действия и реагировать на инциденты эффективнее.

17. Дырка снаружи на порядок хуже, чем та, которой может воспользоваться только авторизованный пользователь. Авторизованных всегда меньше, и они просто по определению должны в подавляющем большинстве быть заинтересованы в том, чтобы не навредить. А вот несвязанных с системой злонамеренных людей может быть больше. Им наплевать, что своими действиями они могут уронить сервер, уничтожить чужой труд. Обращайте повышенное внимание к тому, что можно сделать без авторизации.

18. Если вы считаете, что ЭТОГО никто никогда не сделает или не додумается, и не станете полноценно от ЭТОГО защищаться, знайте: рано или поздно найдется такой маньяк, который обязательно сделает именно ЭТО. Либо сложатся обстоятельства, которые вы не обрабатываете, так как считаете маловероятными.

19. Загрузка файлов на сервер.

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

Для архивов, скриптов, исполняемых, документов и других подобных файлов обязательна антивирусная проверка. Многие любят перекладывать это на плечи пользователя, скачивающего файлы. Почему бы не уменьшить проблему прямо при загрузке? Понимаю, что это не решает всех проблем, ведь можно написать новый вирус и положить его исключительно на атакуемый сервер (чтобы в антивирусные базы не сразу попал), но это не означает, что можно пренебрегать возможностью организовать автоматическое отсеивание известных вирусов.

Не плохо бы заодно проверять сигнатуры файлов. Если прислали с расширением jpg, то и по сигнатуре это должен быть строго JPEG, если zip — то ZIP и ничто иное, иначе отказывать. Разумеется, это касается лишь тех форматов, у которых есть стандартная обязательная сигнатура. Вспомните, вам наверное хотя бы раз на форумах попадался какой-нибудь URL с комментарием вроде «скачайте и смените расширение на rar» — скорее всего это обманутый сервер, сохранивший файл, не проверив сигнатуру. На UNIX-подобных системах для автоматической проверки есть утилита file, по нему есть man. Если формат редкий, его придется проверить вручную. Описания форматов ищите на сайтах вроде Wotsit.org.

Ко временной папке, куда будут загружаться файлы перед обработкой или проверкой не должно быть доступа. Когда загрузка завершена, сделайте (если требуется) необходимые действия, преобразования и тому подобное, и только затем перемещайте в доступное извне место.

В папке, куда попадают загруженные файлы не должны быть включены SSI, PHP, Options ExecCGI, и прочее. Как вариант: отдавайте файлы не вживую, а через скрипт, это позволит вам собрать больше информации и создаст лучшие условия для принятия решений на лету.

При сохранении файла из его имени необходимо удалить все спецсимволы. Я (если не требуется иное) предпочитаю в имени делать следующее:

  • перегнать русский в транслит,
  • оставить латиницу и цифры,
  • остальное заменить знаками подчеркивания
  • и заменить подряд идущие подчеркивания на одно

Это можно сделать примерно так:

# $f - имя файла или папки

# транслит
$f =~ s/ю/yu/go; $f =~ s/Ю/Yu/go;
$f =~ s/я/ya/go; $f =~ s/Я/Ya/go;
$f =~ s/ч/ch/go; $f =~ s/Ч/Ch/go;
$f =~ s/ш/sh/go; $f =~ s/Ш/Sh/go;
$f =~ s/щ/sh/go; $f =~ s/Щ/Sh/go;
$f =~ s/ц/ts/go; $f =~ s/Ц/Ts/go;
$f =~ s/[ьъ]//gio;
$f =~ tr/АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЭ/ABVGDEEJZIYKLMNOPRSTUFHCIE/;
$f =~ tr/абвгдеёжзийклмнопрстуфхцыэ/abvgdeejziyklmnoprstufhcie/;

# замена запрещенных символов на знак подчеркивания
$f =~ s/[^a-z0-9\-()\[\]{}.,@#!\$\%]+/_/ig;

Во избежание злоупотребления возможностью загрузки файлов надо ввести квоты и назначить максимальный размер файла. Отказывайтесь обрабатывать запрос, если клиент умалчивает о размере данных (не передает поле CONTENT_LENGTH).

Не давайте закачивать или управлять файлами за пределами строго определенных директорий. Остерегайтесь приема полных путей и символов конвейера (|) и перенаправления ввода/вывода (<, <<, >, >>). Там, где можно обойтись без обращения к оболочке, потенциально безопаснее так и сделать.

20. Ускорители для Perl, такие как SpeedyCGI, mod_perl, FastCGI — это ваши друзья, но таящие в себе угрозу: глобальные переменные сохраняют значение между обращениями. Если в обычном режиме вы рассчитываете, что они изначально не инициализированы (то есть при обращении неявно приводятся к нулю либо к пустой строке), то под ускорителями они могут незаметно для вас стать уязвимым местом, храня что-либо с предыдущего вызова скрипта. Об этом настойчиво предупреждают в документации к каждому из таких ускорителей, но сказать еще раз лишним не будет.

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

21. Не позволяйте роботам ходить по ссылкам, для них не предназначенным. Информацию, которую вы не хотите отдавать на индексирование роботам-паукам, можно обозначить тегом noindex:

<noindex>Этот текст не индексируется.<noindex>

Всю страницу можно пометить как неиндексируемую, добавив в заголовок HTTP-ответа:

Cache-Control: no-cache

Аналог этого поля в HTML:

<meta http-equiv="Cache-Control" content="no-cache">

Еще один вариант не индексировать страницу средствами HTML:

<meta name="robots" content="noindex">

Запретить роботам переходить по ссылкам на странице:

<meta name="robots" content="nofollow">

Используйте robots.txt. Пустой robots.txt лучше, чем его отсутствие, так как роботы в любом случае его запрашивают. Ни к чему захламлять логи веб-сервера ложными ошибками 404. Это касается и файла favicon.ico. Если у вас его до сих пор нет, может пришла пора его создать? Пустой robots.txt равносилен заявлению «индексировать можно все».

22. Настройте Apache так, чтобы он рассказывал о себе как можно меньше. Эти параметры в конфиге регулируют вывод версии сервера и установленных модулей на некоторые служебные страницы и в HTTP-заголовки:

# добавлять версию сервера в страницы ошибок,
# листинги директорий, и вывод некоторых модулей
ServerSignature   On или Off

# Включать в заголовок HTTP-ответа версию ОС, сервера и установленных модулей
# Рекомендуется значение Prod - включать только слово Apache
ServerTokens      Full или OS или Minor или Minimal или Major или Prod

23. Есть модуль для Apache mod_security. По принципу работы он является одновременно прокси-сервером и фаерволлом для протокола HTTP.

Это модуль, который позволяет создавать правила проверки запросов до того, как они обработаются скриптами, и фильтрации ответов после отработки скриптов.

Например, можно раз и навсегда защититься от SQL-Injection в параметре с именем id, если вы будете придерживаться правила использовать его только для передачи числа, то есть создадите правило, что id в запросе должно быть числом и ничем иным. Можно также указать диапазон допустимых символов в запросе. Используя регулярные выражения можно добиться достаточно гибкой фильтрации. Модуль умеет произвольно реагировать на несоблюдение правил, его можно, к примеру, настроить так, чтобы злоумышленник после нескольких неудачных попыток сломать сайт банился бы по IP или ему показывалась заданная страница. Приятная особенность: mod_security умеет фильтровать и вести логи POST-запросов.

Можно отсеять вывод той информации, которая является секретной, и которую злоумышленник попытается вытащить. Таким образом, даже если уязвимый скрипт что-то выдаст, вы можете вырезать это из ответа при помощи mod_security.

Модуль может обрабатывать практически любой аспект взаимодействия с веб-сервером, он настолько мощен, что его просто нельзя описать кратко, он достоин скорее отдельной статьи, или даже книги.

24. Принимайте меры против подбора пароля.

Задержка входа — простейший, но при этом уже достаточно неслабый способ. Сведет на нет ручной перебор и автоматический однопоточный перебор с одного компьютера.

use Time::HiRes;

# Перед проверкой логина/пароля
Time::HiRes::sleep(0.5);

Против многопоточного перебора поможет ограничение количества неудачных попыток войти под одним и тем же логином в течение определенного промежутка времени. После их превышения срабатывает временная блокировка логина. Отправная точка — пять попыток за три минуты (это и слишком жесткие правила для целесообразности автоматического подбора хорошего пароля и сносная пауза для человека, если он опечатался несколько раз подряд).

Храните неудачные попытки входа примерно в такой табличке:

login datetime
vasya 25.12.2007 09:08:57
test 25.12.2007 09:09:06
admin 25.12.2007 09:09:31
admin 25.12.2007 09:09:39
admin 25.12.2007 09:09:50
admin 25.12.2007 09:10:06
admin 25.12.2007 09:10:22

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

Жестоко? Ничуть. Как сказал один мой коллега: «Не можешь ввести пароль за пять попыток — иди проспись». Весьма метко. Только представьте себе человека, которому пять попыток ввода пароля не принесли успеха. В таком состоянии он сам может стать угрозой. А если он забыл пароль, будет время попытаться вспомнить, спокойно и неторопливо.

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

Против распределённого перебора паролей с разных компьютеров поможет ограничение количества неудачных входов в единицу времени с одного IP. При нарушении на время блокируется связка IP-логин. Это заодно решает проблему с намеренной блокировкой логина: самое страшное, что может случится после блокировки — admin не сможет войти в течение пяти минут всего лишь на одном компьютере.

ip login datetime
192.168.0.15 vasya 25.12.2007 09:08:57
192.168.0.17 test 25.12.2007 09:09:06
192.168.0.10 admin 25.12.2007 09:09:31
192.168.0.10 admin 25.12.2007 09:09:39
192.168.0.10 admin 25.12.2007 09:09:50
192.168.0.10 admin 25.12.2007 09:10:06
192.168.0.10 admin 25.12.2007 09:10:22

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

proxy_ip datetime
192.168.0.1 25.12.2007 09:08:57
192.168.1.1 25.12.2007 09:09:06
192.168.1.1 25.12.2007 09:09:50
192.168.4.1 25.12.2007 09:10:06
192.168.0.1 25.12.2007 09:10:22

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

При вводе или смене пароля проверяйте его на слабость. Прежде всего недопустим пустой пароль. Помимо традиционного пробивания по словарю я советую самостоятельно дописать специфические проверки под ваше приложение в зависимости от заполняемых пользователем полей при регистрации. Итак, пользователь вводит о себе какую-то информацию. Система не должна позволить ему задать себе пароль, равный логину, имени, фамилии, дате рождения в вариациях формата (то есть если введена дата рождения 05.10.1992, то пароль не должен быть таким: «05.10.92», «05/10/92″, «05-10-92″, «051092», «1992» и так далее), номеру телефона или ICQ, городу и тому подобное. Эту информацию часто можно легко найти на других сайтах и форумах в профиле и попробовать в качестве пароля.

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

sub bad_password
{
  my $p = shift;

  # слишком короткий
  # например, 1, r, gg, cc, 4$, sp, hz, 00, vG, $!
  return 1 if length $p < 3;

  # весь пароль состоит из повторяющегося символа
  # например, qqq, 111, 7777777777, XXXXXXXX, %%%%
  # эту проверку не заменит ни один словарь
  my %c;
  $c{$_}++ for split '', $p;
  return 1 if scalar keys %c == 1;

  # пароль из трёх символов, два из которых совпадают
  # например, qqw, 112, ccd, 545, 1ff, x00, ppc
  return 1 if scalar keys %c == 2 and length $p == 3;

  return 0;
}

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

Для параноиков также можно проверить совпадение без учета регистра, в транслите, в другой раскладке, без пробелов, сочетание полей или их укороченные версии от 1 символа до полной длины.

Развивая тему алгоритмов проверки пароля на слабость, можно дополнить предыдущий пример функции совсем уж маньячными проверками. Например, так:

sub bad_password
{
  my $p = shift;

  # проверка чтобы пароль не был последовательностью кнопок на клаве
  my @key_sequence = (
    q[abcdefghijklmnopqrstuvwxyz],
    q[qwertyuiop],
    q[asdfghjkl;'],
    q[zxcvbnm],
    q[0123456789],
    q[~!@#$%^&*()_+|] # верхний ряд с шифтом
  );
  for (@key_sequence) { return 1 if $_ =~ /\Q$p\E/i; }
  # то же самое в обратном направлении
  $_ = reverse $_ for @key_sequence;
  for (@key_sequence) { return 1 if $_ =~ /\Q$p\E/i; }

  # пароль - это идущие подряд кнопки на клавиатуре по диагонали
  my @key_sequence2 = (
    '1qaz', '2wsx', '3edc',  '4rfv', '5tgb', '6yhn', '7ujm', '8ik,',
    '9ol.', '0p;/', "\\]'/", '=[;.', '-pl,', '0okm', '9ijn', '8uhb',
    '7ygv', '6tfc', '5rdx',  '4esz'
  );
  for (@key_sequence2) { return 1 if $_ =~ /\Q$p\E/i; }
  # то же самое в обратном направлении
  $_ = reverse $_ for @key_sequence2;
  for (@key_sequence2) { return 1 if $_ =~ /\Q$p\E/i; }

  # слабый пароль: длина 1-8 символов, содержит одни цифры
  return 1 if length $p <= 8 and $p =~ /^[0-9]+$/;

  return 0;
}

Если подобные ограничения кажутся вам слишком жестокими, не запрещайте устанавливать такой пароль, но все-таки проверьте его подобным образом, предупредите пользователя о том, что его пароль слишком простой, его не трудно угадать/подобрать, и рекомендуйте усложнить пароль.

Подумайте и о возможности перехвата пароля при входе или смене. Раз уж кто-то может слушать трафик пользователя (например, недобросовестный админ прокси, провайдер либо сосед пользователя по сети (если они на хабах, а не на свитчах)), то, ясное дело, применение MD5 и тому подобных алгоритмов избавления от необходимости передавать пароль открытым текстом не имеют смысла (потому что злоумышленник подставит зашифрованный или захеширванный пароль в запрос на вход и, даже не зная пароля, войдет). Остается шифровать само HTTP-соединение, то есть использовать HTTPS.

Никогда не отправляйте пароли методом HTTP/GET. Только POST. Во-первых, они сохраняются в логах Apache, во-вторых, в зависимости от настроек браузера, могут попасть в историю посещения, в логи другого сервера или хуже того — в публичную статистику через Referer.

25. У сеанса обязательно должен быть (настраиваемый) тайм-аут (максимальная длительность бездействия пользователя). Ваша многогранная защита может сойти на нет, если сам пользователь или обстоятельства подвергнут опасности сеанс.

Представьте себе такие ситуации:

  • Пользователь не завершил сеанс и закрыл браузер. После его ухода можно запустить браузер и пройтись по нескольких URL из истории. Нетрудно догадаться, что если системе никто не объяснил, что пользователь с таким IP, с таким User-Agent, с такими Cookie и т. п. сеанс не завершил, то он продолжится как ни в чем не бывало.
  • Еще хуже, если человек вовсе не закрыл браузер и тем самым передал все свои права в руки любого имеющего доступ к компьютеру.
  • Человек торопится уходить, но еще есть время поработать в вашей системе. За пять минут до его ухода происходит авария и электропитания в здании нет. Запасного тоже нет (у машины не было бесперебойника). Человеку некуда деваться (ждать нельзя), он уходит. Через час электропитание восстановлено. Включаем компьютер, запускаем браузер и опять получаем в свое распоряжение тот самый сеанс.

Если ситуация из последнего примера кажется вам чересчур надуманной, смотрите пункт 18 (про маньяков и закон подлости).

Вывод: должен быть тайм-аут. Делается просто. При входе и успешном выполнении любого допустимого действии, которое выполняется авторизованным пользователем, для его логина или id записывается текущее время (то есть время последней активности). А до выполнения такого действия текущее время сравнивается со временем, записанным в вышеописанной таблице для данного пользователя. Если разница превышает тайм-аут, то пользователю работать дальше запрещается, а его сеанс завершается. Здесь же заодно подходящее место для вывода формы входа.

26. Отладка.

Во-первых, отладочная информация должна быть доступна только разработчикам или тестировщикам во время разработки. Так как отладочная информация раскрывает подробности реализации приложения, структуры и внутреннее представление данных, примененные технологии, форматы и стандарты, это ни в коем случае нельзя показывать посторонним.

Рассмотрите варианты, как можно организовать отладочный режим:

  • Приложение отлаживается на сервере, недоступном снаружи (самый безопасный вариант).
  • Недокументированная настройка — переключение в отладочный режим.
  • Доступ к отладочной информации имеют пользователи либо по IP, либо только определённые учетные записи.

Итак, что же я понимаю под отладочной информацией.

  • Время выполнения всего скрипта или его части (функции или куска кода).
  • Количество SQL-запросов, их текст, время их выполнения.
  • Нагрузка на процессор скриптом, базой, расход памяти.
  • Сообщения об ошибках и предупреждения интерпретатора в браузер, а не в лог.
  • Дампы выполнения вспомогательных команд, вызывавшихся во время исполнения скрипта.
  • Трассировка стека.
  • Дамп переменных окружения, HTTP-запроса.
  • На страницы вместе с информацией из базы выводятся внутренние идентификаторы (например, id пользователей, сессий, новостей, статей, событий, да, в принципе, любые идентификаторы данных, которые порой очень облегчают разбор происходящего без лишних подглядываний в базу).

Пара слов о реализации. Я предпочитаю заводить переменную вроде $DEBUG. В разработке она = 1, иначе это релиз. И теперь можно писать либо так:

if ($DEBUG)
{
  # что-то для отладки, чего нельзя делать в релизе
}

Так можно подсчитывать количество запросов к БД, замерять длительность их выполнения, сообщать о повторяющийся запросах.

Либо так:

if ($DEBUG)
{
  # отладочная версия блока кода
}
elsif
{
  # релизная версия блока кода
}

Например, в отладке в списках можно к тексту элемента дописывать его ID в базе. Или добиться, чтобы в отладке проект не выдавал предупреждений на JavaScript при выходе или удалении информации, так как вам это скоро надоест, а данные тестовые — их не жалко в случае чего (да и бэкапы вы всё равно делаете, не так ли?).

Internal Server Error — знакомо? Пусть в релизе так и остаётся. Так как уж очень интересные вещи порой Perl может поведать взломщику. Максимум, что тут можно сделать, так это назначить в apache

ErrorDocument 500 /500.html

и написать там по-русски об ошибке с учетом того, какое у вас приложение. А технические детали вы в логах почитаете.

Вроде красиво, вроде безопасно… но так неудобно отлаживать — приходится все время читать хвост error_log. Есть способы выводить многие ошибки в браузер. Почему не все? Дело в том, что ошибка может возникнуть ещё до выполнению приведенных ниже трюков, и они просто не успеют отработать.

Итак, направить ошибки в браузер в режиме отладки можно либо по-старому:

use CGI::Carp qw/fatalsToBrowser/;

либо с использованием более легкого, чем CGI.pm модуля Котерова:

use CGI::WebOut;

27. Необходимо четко следить, чтобы доступ к базе производился только приложением со всеми должными проверками и фильтрациями. Надо отфаерволлить базу от любых подключений, кроме локального (если база и приложение на одном сервере), либо кроме локального и только того, на котором находится приложение. Это избавит вас от необходимости проверять данные при чтении из базы (потому что они попали в нее гарантированно только от вашего приложения и проверены/профильтрованы им), кроме того, эффективнее проверять один раз при добавлении/изменении, а не каждый раз при чтении.

28. Доступ к приложению всем либо конкретным пользователям можно дать только с определённых IP или подсетей.

29. Можно устроить блокировку доступа к веб-приложению по выходным, в отпуске либо по определённым дням, в течении которых доступ к приложению совершенно точно никому не нужен. Если приложение полностью не доступно, то и сломать его не получится. Это лучше, чем оно могло бы быть поломано всегда. Правило применимо только к специфическим приложениям, которые крутятся, скажем, на предприятиях, работающих с понедельника по птницу, а в выходные не работающих. Причём для исключения человеческого фактора (типа, забыли погасить сервер) можно назначить в кроне задание выключиться в пятницу вечером, а в BIOS Setup — включиться в понедельник утром. Ведь ни для кого не секрет, что лучше всего сервер защищён только выключенным состоянием :) Естественно, идея выключенного сервера годна лишь для такого сервера, где кроме защищаемого веб-приложения ничего не крутится (в первую очередь в голову приходит пример с официальным сайтом, который должен работать круглосуточно. Если у вас именно так, то укладывать веб-приложение вместе с сайтом нельзя и надо решать иначе, например временным пятничным выпиливанием VirtualHost приложения из конфига apache и его же понедельничным возвращением назад).

30. Кривые скрипты по соседству могут свести на нет все ваши старания. Например, форум стороннего производителя. Скажем, в нём нашли уязвимости, а вы обратили внимание о новости слишком поздно — это угроза. Либо галерея картинок, писаная начинающими ради уникальных фич, но без полноценного учёта безопасности — тоже яркий пример. Если ваш проект настолько крут и важен, что вы боитесь за него из-за соседей на shared-хостинге, просто возьмите себе VPS или даже DS. Тогда вас будет труднее положить, у вас будет больше производительности и не будет таких соседей, которых стоит бояться на shared-хостинге.

31. К младшему брату, языку PHP, существует интересный патч под названием suhosin. Он добавляет к языку довольно мощные инструменты защиты скриптов. Прочитайте список возможностей. Это превосходный источник идей, которые вы можете реализовать на Perl для защиты собственных проектов.

32. NULL-байт.

Существует опасность при появлении NULL-байта в строке, полученной от пользователя. Это символ, числовой код которого равен нулю. Сразу же приведу пример, где это очень наглядно видно:

#!/usr/bin/perl -w
use strict;

# Демонстрация поведения Perl при наличии в строке NULL-байта.

# Напишите произвольный текст в файл с именем aaa,
# положите рядом с этим скриптом и запустите.

# Посреди обычной строки находится байт с кодом 0
my $string = "aaa\x00.txt";

# Пытаемся открыть файл с таким именем.
# Вопреки содержимому строки $string,
# Perl откроет файл с именем aaa. Часть ".txt" будет отброшена.
open F, '<', $string;

# А здесь вы увидите содержимое файла aaa
# И больше никаких ошибок и даже предупреждений.
print <F>;

Когда пользователь передал в программу на Perl строку, в середине которой имеется нулевой байт, вся строка попадает в переменную как есть. То есть всё, что идёт до нулевого байта, сам нулевой байт и всё, что после, будет сохранено в строке. Как только вы попытаетесь открыть файл с именем, хранящемся в такой переменной, нулевой байт будет означать конец строки, потому что Perl написан на языке C, а для языка C нулевой байт — это как раз символ окончания строки. Поэтому имя открываемого файла в конечном счёте будет воспринято внутренней сишной природой перла как строка до нулевого байта, остаток байт в перловой строке будет проигнорирован.

Представьте себе, что вы написали защиту, которая опирается на проверку строки от пользователя с помощью регулярного выражения. Вы хотите, чтобы это был исключительно текстовый файл. Вы сравниваете строку с выражением /\.txt$/ и считаете, что на этом задача решена. Однако пользователь может передать вам строку «config.pl\x00.txt». Такая строка пройдёт проверку, ведь у неё в конце по версии перлового представления строк есть фрагмент .txt, а какой файл откроется на самом деле, вы уже догадались? :)

Уязвимость чинится очень просто: как только вам передали имя файла, нулевой байт вырезается ($str =~ s/\0//g;) а лучше сразу фильтровать всё, кроме алфавитно-цифровых символов, точек, минусов, подчёркиваний, словом, всего того, из чего может состоять имя файла, который допустимо открыть.

33. Ключи запуска Perl.

-T
Не доверять пользователю. Если вы попытаетесь использовать данные от пользователя напрямую (Cookie, переменные окружения, параметры, переданные по CGI, ввод с клавиатуры и т. п.), Perl выдаст предупреждение о том, что такие данные требуется отфильтровать. Например, с помощью регулярного выражения выполняется извлечение, и только использование результата такого извлечения пройдёт без предупреждения.

-w
Показывать предупреждения о проблемах, которые не фатальны, с которыми скрипт работать будет, но обратить внимание на которые стоит как с точки зрения аккуратного программирования, так и с точки зрения безопасности. Одно из самых полезных предупреждений, на мой взгляд, использование переменной, которой ничего не присвоено, то есть то же, что и undef. Проверить неопределённость переменной можно так: defined($var). Когда вы добьетесь чистого выполнения скрипта без предупреждений с ключом -w в самых разных условиях, которые только сумеете придумать, вы как минимум теоретически усилите безопасность скрипта.

-W
То же, что и -w, только Perl покажет больше предупреждений, часть из которых совсем уж незначительна. Например, часто можно видеть, как при -W Perl ругается даже на собственные модули из коробки! Это, безусловно, отвлекает. С этим особо нечего поделать. Тут придётся либо на время наведения железного порядка у себя в скрипте смириться и игнорировать их, либо принять участие в улучшении чужого модуля — сообщество программистов будет вам признательно :) Только перед тем, как чинить чужое, убедитесь что у вас именно последняя версия модуля.

-X
Заткнуть Perl от любых предупреждений. Сообщения о фатальных ошибках при этом останутся. Этот ключ полезно использовать тогда, когда вы знаете о проблемах, отложили их починку, но хотите, чтобы в логах веб-сервера не было предупреждений. Не обязательно так делать, это всего лишь способ не засорять логии тем, о чём вы и так в курсе (представляете, что будет, если Warning выдаётся во фрагменте кода с большими циклами? Так что это ещё и экономия места на диске с логами, может быть важно для стартапов). Также это снизит нагрузку на диск при интенсивной ругани Perl-а предупреждениями.

Однако, это палка о двух концах. С одной стороны вы не видите известные проблемы (и это скорее хорошо, если исправить их запланировано), с другой — не видите и те, о которых не знаете. Это опять же касается стартапов, где может не быть лёгкой возможности подставить сервер с отлаживаемой версией под реальную нагрузку. Вам придётся принять решение, что для вас важнее: производительность с -X или возможность зафиксировать максимум предупреждений в логах на боевой машине и некоторое падение производительности с -w или даже -W.

34. Модуль strict

Это уже не просто совет, это настоятельная рекомендация. Модуль очень полезный, он заставляет вас писать более строго.

Варианты:

  • strict vars
    Без этого вы используете переменную без объявления, считая, что Perl создаёт её автоматически. С этим вариантом вы должны объявить каждую переменную (массив, хеш… абсолютно всё).
    Полезно вот что: если вы вдруг опечатались, Perl вас обругает, такая переменная не объявлена (компиляция будет остановлена). В обычном режиме Perl «заботливо» создаст для вас новую переменную с новым именем, таким, как вы опечатались.
    И внутри у неё будет, разумеется, undef, что в выражении приведётся к нулю или пустой строке. При присваивании будет ещё хуже. Вам покажется, что всё хорошо (синтаксически-то всё безупречно), а на деле присваивание произойдёт мимо нужной переменной в новую.
  • strict subs
    Perl обычно не ругается, если вы присвоите переменной имя процедуры как есть,
    голое название без кавычек. А каогда вы включите strict subs, Perl не позволит вам так сделать, вы обязательно должны будете либо заключить название в кавычки,
    либо использовать особый синтаксис:

    \&название_процедуры
    

    что является рекомендуемым.

  • strict refs
    Похоже на strict subs, только действует на ссылки. Запрещает использовать имя переменной, на которую ссылается ссылка в виде строки. Допустим только особый синтаксис:

    \$something
    

use strict без параметров включает все 3 описанных выше ограничения, что по мне самый классный вариант.

35. Давайте коллегам читать код и тестировать новые фичи продукта. Практика показывает, что вы сами способны найти меньше ошибок, чем другой человек (лучше, чтобы это был опытный тестировщик), как минимум потому, что он не знает, что и как вы реализовали, и что вы могли не учесть.

36. Храните резервные копии проекта. ОБЯЗАТЕЛЬНО!

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

Не ленитесь, даже если вам очень некогда или очень не хочется, бэкапы ещё никому не мешали, а вот спасали не раз.

37. Сделайте так, чтобы ваше приложение при обнаружении попытки взлома отправило вам уведомление (по почте, Jabber-у, ICQ и т. п., главное, чтобы оно доставилось мгновенно, SMS — неплохой вариант).

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

Если он действительно что-то нащупал, у вас будет шанс срочно принять меры, пока он не успел это поломать.

Хуже того, если он успел поломать, он мог поделиться рецептом со своими друзьями-взломщиками, чтобы и они смогли вдоволь похимичить.

Чем раньше вы узнаете об уязвимости, тем лучше.

38. Во всём вашем проекте, кроме мест, где это действительно необходимо и чётко обоснованно, запретите листинг директорий.

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

Когда листинг где-то всё-таки включен, сделайте так, чтобы в robots.txt было запрещено ходить туда поисковикам (кроме случаев, когда индексация — это одна из целей).

39. Консольный инструмент perltidy предназначен для форматирования исходников на перле. То, как текст будет отформатирован, определяется множеством параметров.

Самый интересный из них — возможность удалить комментарии.

Сама по себе это не очень-то и защита, но если ваш проект отдаётся куда-то, где есть шанс попадания на рапидшару, что его будут против вашей воли изучать, ломать, пускай они читают сорцы без комментариев, в нечеловеческом виде, когда весь код без деления на блоки, в одну строчку, и т. п., как я это называю, в виде «перловой каши»).

Усложнить поиск уязвимостей столь лёгкой ценой — тоже полезное дело.

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

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

40. Не позволяйте ломать вас через другие сервисы на этом же сервере.

Например, если у вас всё супернадёжно защищено в веб, но при этом поднят ssh, который доступен со всех IP на стандартном порту 22 и пароль у рута 123, то последствия однажды могут быть самыми печальными. Это же касается FTP и т. п.

Советы по административному доступу:

  • Сажайте сервисы на нестандартный порт.
  • Не давайте доступ учётным записям с очевидными логинами, такими как root, adm или совпадающим с никами администраторов.
  • Тем учётным записям, кому разрешено админить сервер удалённо, задавайте страшные длинные неугадываемые пароли. Здесь справедливо правило № 24 о паролях.
  • Определите список или диапазоны IP-адресов, с которых можно администрировать, о остальных доступ запретите. И лучше не в настройках сервиса, а сразу на фаерволе.

Почему лучше резать на фаерволе, а не в конфигах? В случае с запретом на сервисе, он самостоятельно будет отказывать взломщику. Кроме того сервис в процессе авторизации может выдать своё название и версию, что даёт повод поискать уязвимости в этой версии. А в случае защиты на фаерволе, к сервису запросы не дойдут вообще. Никакого разговора между взломщиком и сервисом не состоится. Ещё лучше, чтобы с наружи не было разницы между зафаерволенным сервисом и ничем непрослушиваемым портом.

Комментариев: 13 | RSS

2 января 2010 в 14:37 #
Автор: Pilat

24. Принимайте меры против подбора пароля.

Задержка входа — простейший, но при этом уже достаточно неслабый способ. Сведет на нет ручной перебор и автоматический однопоточный перебор с одного компьютера.

только задосить такой сайт — дело минут.

2 января 2010 в 20:53 #
Автор: Zesicesse

Начало многообещающее. Добавил в закладки, завтра дочитаю :)

3 января 2010 в 06:32 #
Автор: DJ-Andrey-sXe

Pilat, 24-й пункт непростой, если следовать только его части, то это реально чревато и DoS и нежелательными блокировками. А если всё продумано и предусмотрено, то попытавшийся положить сервер атакой на задержку входа (полагаю, вы имели ввиду, рассчитывая израсходовать ресурсы или их лимиты, многопоточно калбася скрипты, не дожидаясь ответа) очень быстро влетит в бан по порогу «Requests per second» на заслуженный отдых.

Мой друг как-то предложил идею хранить месяц истории попаданий в бан и если побывавший в бане IP снова возжелал покрошить сервер, то новый бан следует выдавать на более длительный срок. Например, вернулся после 5 минут — выдаём 10, вернулся ещё раз — 30, ещё — 2 часа, ещё — сутки. Ну, если уж делать, то что в веб-морде должен быть список банов с функцией преждевременного снятия, это, думаю, и так понятно.

4 января 2010 в 02:55 #
Автор: KoXX

Интересно, а главное познавательно Спасибо :)

7 января 2010 в 04:23 #
Автор: Willie

Ну наконец! Свежая инфа. Как раз доклад делаю по смежной теме

8 января 2010 в 12:40 #
Автор: Zenon

Спасибо. Очень понравилось :)

8 января 2010 в 14:09 #
Автор: Vavila

Да, а ведь написано действительно хорошо..

13 марта 2010 в 14:16 #
Автор: Paul

Вообще, если всё это реализовать, уйдёт очень много времени.

13 марта 2010 в 21:26 #
Автор: DJ-Andrey-sXe

Paul,

1) Не каждый проект требует выполнения всех пунктов подряд. Элементарно может не быть таких функций, которые потребовали бы именно такого типа защиты или специфика может освободить от выполнения многих пунктов.

2) Защита — важная вещь, не стоит жалеть на неё времени. Потому что когда проект сломают, сразу станет понятно, что время потратить всё-таки стоило.

3) Если защищать по полной программе первый проект, это трудно. Второй и последующие защищаются частично прямо во время написания, когда правила уже в голове, частично после, заимствованием своего же собственного кода из предыдущих защищённых проектов. Таким образом экономится время.

7 августа 2010 в 21:45 #
Автор: DJ-Andrey-sXe

Старая версия с 18 пунктами (та, что первая публичная, аккуратная, без этих свежих добавок с TODO) всё-таки сохранилась: http://web.archive.org/web/20080214113508/http://rusdj.chat.ru/articles/perl-webapp-protection/perl-webapp-protection.html

12 ноября 2010 в 23:29 #
Автор: Alex

К пункту 21. Да, наличеее файла robots.txt более чем необходимо.

3 декабря 2010 в 03:21 #
Автор: Arch

Cтатья отличная, много почерпнул, много переосмыслил. СПАСИБО за материал!

19 декабря 2010 в 23:54 #
Автор: DJ-Andrey-sXe

На основе статьи сделан подкаст.
TODO: на хабр (не взяли), на арпод, на опеннет, в ][, и прочие годные журналы. И неплохо бы якори развесить по пунктам, чтобы давать ссылки можно было удобно, сразу на пункт.

Добавить комментарий: