WAF — своими руками

от автора

Здравствуйте!

В данной статье мы рассмотрим пример реализации шлюза вэб фильтрации на базе ПО с открытым исходным кодом (стек Apache/ModSecurity/OWASP CRS/ProjectHoneyPot).

Посмотрим как это все объединить в одно целое. Где взять и как подгрузить сигнатуры. Как отладить (дифолтные конфигурации — не наш метод). В процессе напишем пару дополнительных правил, наступим на грабли, найдем что‑то похожее на изящный выход из ситуации. Узнаем как собирать статистику о ботах и как от них защититься.

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

Штош,приступим!

Введение

Для начала отвечу на один из главных вопросов — почему OpenSource? — Все просто, можно взять F5, Imperva, есть решения и у Fortinet, и у Check Point, и у Palo. Конечно же есть решения Российских вендоров. Взять‑то конечно можно, но вот доступ к таким технологиям имеет лишь не самая большая аудитория. По этой причине рассматриваем OpenSource

Очень актуальный сегодня вопрос согласования с ИБ оставим за скобками. Риски, как ни крути присутствуют и я бы 10 раз подумал (и отказался) прежде чем реализовывать нечто подобное для защиты критической инфраструктуры.

В то же время, данное решение может подойти малому бизнесу и энтузиастам в ИТ. Ведь сколько у нас приложений опубликовано в сеть с минимальной фильтрацией? А сколько из них обновляется?

И так, что же такое WAF?

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

Вендорские решения охватывают несколько больший функционал. Как правило это:

  • Анализ трафика приложения облегчающий составление политик фильтрации

  • Фильтрация OWASP сигнатурами (защита от XSS, SQL инджектов, анализ HTTP заголовков, защита от известных уязвимостей нулевого дня и так далее)

  • Защита от ботов

  • Некоторые вендоры в довесок предоставляют функционал NGFW. Здесь вам и песочница, и IPS, и защита от вредоносного ПО. Все стандартно.

  • Так же встречаются WAF с функцией балансировщика

Что из этого мы можем реализовать? — Многое. Придется ли при этом страдать изобретая велосипед? — Несомненно

  • Анализ, репорты? Только своими руками. Можно (и даже нужно) конечно логи перенаправлять в SIEM именно с этой целью

  • OWASP? Ну, да. Никаких вам графических интерфейсов и только хардкор

  • Детекция приложений? Теоретически тоже да. Здесь придется писать собственные сигнатуры

  • Защита от ботов? В какой‑то мере.

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

Но в общеобразовательных целях мы будем рассматривать OpenSource

ModSecurity

ModSecurity есть ничто иное как кросс‑платформенный модуль шлюза вэб фильтрации для вэб сервера. Поддерживается как Apache, так и Nginx. Может выполнять фильтрацию своими силами, а может использовать сигнатуры OWASP CRS

Установка здесь тривиальная и не займет много времени. Предположу что у вас уже есть Linux дистрибутив и вэб сервер (здесь и далее рассматриваем Debian + Apache)

  • Устанавливаем пакет

apt install libapache2-mod-security2
  • Включаем модуль

a2enmod security2
  • Подгружаем рекомендованный файл конфигурации

mv /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
  • Проверяем режим работы движка (Оставляем в DetectionOnly. У нас будет адаптивная блокировка)

grep SecRuleEngine /etc/modsecurity/modsecurity.conf
  • Проверяем включен ли скан ответов сервера

grep SecResponseBodyAccess /etc/modsecurity/modsecurity.conf
  • Проверяем все ли в порядке с конфигом модуля

cat /etc/apache2/mods-enabled/security2.conf
  • Перезагружаем Apache

systemctl restart apache2 systemctl status apache2
  • В принципе какой‑то дополнительной настройки можно и избежать

OWASP CRS

Что же это такое? Не более чем общий набор статических правил оценки и фильтрации трафика для ModSecurity

Установка

  • Подгружаем правила

cd /var/tmp  wget https://github.com/coreruleset/coreruleset/archive/refs/tags/v4.10.0.tar.gz wget https://github.com/coreruleset/coreruleset/releases/download/v4.10.0/coreruleset-4.10.0.tar.gz.asc
  • Проверяем целостность

gpg --fetch-key https://coreruleset.org/security.asc gpg --edit-key <RSA KEY ID>  gpg> trust Your decision: 5 (ultimate trust) Are you sure: Yes gpg> quit  gpg --verify coreruleset-4.10.0.tar.gz.asc v4.10.0.tar.gz
  • Устанавливаем правила

mkdir /etc/crs4 tar -xzvf v4.10.0.tar.gz --strip-components 1 -C /etc/crs4
  • Подгружаем файл конфигурации

cd /etc/crs4 mv crs-setup.conf.example crs-setup.conf
  • Подгружаем в ModSecuirty модуль

nano -w /etc/apache2/mods-enabled/security2.conf
# Include OWASP ModSecurity CRS rules if installed #IncludeOptional /usr/share/modsecurity-crs/*.load IncludeOptional /etc/crs4/crs-setup.conf IncludeOptional /etc/crs4/plugins/*-config.conf IncludeOptional /etc/crs4/plugins/*-before.conf IncludeOptional /etc/crs4/rules/*.conf IncludeOptional /etc/crs4/plugins/*-after.conf
  • Перезагружаем Apache

systemctl restart apache2 systemctl status apache2

Настройка

  • Разбираемся с конфигом. Конфиг, если его прочитать, сам ответит на все возникающие вопросы.

nano -w /etc/crs4/crs-setup.conf
  • Проверяем, что модуль работает в Anomaly Scoring режиме

SecDefaultAction "phase:1,log,auditlog,pass" SecDefaultAction "phase:2,log,auditlog,pass"
  • Выставляем Paranoia Level = 3 (А почему бы и да? Секурность мы с вами повысим, а с ложными срабатываниями разберемся при необходимости) Кто бы знал, во что это выльется 🙂

SecAction \     "id:900000,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.blocking_paranoia_level=3"
  • Принудительно включаем Body Processor URLENCODED для всех клиентских запросов с отсутствующим Content‑Type заголовком

SecAction \     "id:900010,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.enforce_bodyproc_urlencoded=1"
  • Задаем параметры (веса?) для аномалий

SecAction \     "id:900100,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.critical_anomaly_score=5,\     setvar:tx.error_anomaly_score=4,\     setvar:tx.warning_anomaly_score=3,\     setvar:tx.notice_anomaly_score=2"
  • Задаем пороговые значения для блокировки. Предлагаю задрать пока не отладим.

SecAction \     "id:900110,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.inbound_anomaly_score_threshold=5000,\     setvar:tx.outbound_anomaly_score_threshold=4000"
  • Включаем второй уровень логирования (Как‑никак у нас PL=3 и видеть что именно происходит было бы крайне полезным)

SecAction \     "id:900115,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.reporting_level=2"
  • Включаем дефолтный набор сигнатур

SecAction \     "id:900130,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.enable_default_collections=1"
  • Включаем ограничение HTTP методов. Я добавил PUT метод используемый CMS (Здесь и далее — осторожнее, ибо многое зависит от опубликованных приложений. Я писал под себя, по этому внимательно и без копипасты) Подробнее про логику здесь и здесь

SecAction \     "id:900200,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT'"
  • Ограничиваем HTTP Content Type

SecAction \     "id:900220,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |text/html|'"
  • Ограничиваем HTTP версии

SecAction \     "id:900230,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:'tx.allowed_http_versions=HTTP/1.1'"
  • Ограничиваем кодировку

SecAction \     "id:900280,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:'tx.allowed_request_content_type_charset=|utf-8|'"
  • Ограничиваем количество аргументов, длину имени аргумента и его собственную длину, суммарную длину всех аргументов.

SecAction \     "id:900300,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.max_num_args=255"
SecAction \     "id:900310,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.arg_name_length=100"
SecAction \     "id:900320,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.arg_length=400"
SecAction \     "id:900330,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.total_arg_length=64000"
  • Ограничиваем размер файла в POST запросе, а так же суммарный размер всех файлов

SecAction \     "id:900340,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.max_file_size=1048576"
SecAction \     "id:900350,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.combined_file_sizes=1048576"
  • Проверяем наш сервер на предмет поддержки кодировки

SecAction \     "id:900950,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.crs_validate_utf8_encoding=1"
  • Остальное оставляем по умолчанию

  • Включаем фильтрацию в конфигурации виртуального хоста

nano -w /etc/apache2/sites-enabled/null.conf
# WAF <IfModule security2_module>   SecRuleEngine On </IfModule>
  • Перезагружаем Apache

systemctl restart apache2

Отладка

  • Берем напильник и приступаем. В одной руке — терминал

tail -f /var/log/apache2/modsec_audit.log | grep -iE ".*notice.*|*.warning.*|.*error.*|.*critical.*"
  • Другой — начинаем проверять функционал приложения

Проблема 1

Message: Rule 7faba9cd8db8 [id "951250"][file "/etc/crs4/rules/RESPONSE-951-DATA-LEAKAGES-SQL.conf"][line "365"] - Execution error - PCRE limits exceeded (-47): (null).
  • Здесь каких‑то рекомендаций кроме как trial and error нет. Предлагаю сделать следующее (не забываем рестартить Apache после внесения изменений)

nano -w /etc/modsecurity/modsecurity.conf
# PCRE Tuning # We want to avoid a potential RegEx DoS condition # SecPcreMatchLimit 500000 SecPcreMatchLimitRecursion 250000

Проблема 2

Message: Warning. Match of "within %{tx.allowed_request_content_type}" against "TX:content_type" required. [file "/etc/crs4/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1012"] [id "920420"] [msg "Request content type is not allowed by policy"] [data "|multipart/form-data|"] [severity "CRITICAL"] [ver "OWASP_CRS/4.10.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/255/153"] [tag "PCI/12.1"]
  • Объективно у нас отсутствует Content Type = multipart/form‑data. Фиксим

nano -w /etc/crs4/crs-setup.conf
SecAction \     "id:900220,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |multipart/form-data| |text/html|'"

Проблема 3 — фундаментальная

Message: Warning. Found 24 byte(s) in ARGS:name outside range: 32-36,38-126. [file "/etc/crs4/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1611"] [id "920272"] [msg "Invalid character in request (outside of printable chars below ascii 127)"]  пример ARGS:name: \xd0\x9e\xd1\x82\xd0\xbb\xd0\xb0\xd0\xb4\xd0\xba\xd0\xb0
  • Все дело в том, что OWASP CRS отказывается в кириллические кодировки, а у нас именно тот случай. (я пробовал подсунуть ему и windows-1251 charset, и koi8-r — безрезультатно. Разработчик отделывается общими фразами аля «дефолтный конфиг работает». Не знаю, не проверял, да и зачем нам дефолт? Что либо с этим сделать не понижая PL практически невозможно, посему пишем байпас.

SecRule ARGS:name "@rx (.*)" \         "id:001001001,\         phase:1,\         pass,\         nolog,\         msg:'Charset Check Bypass - Cyrilic encoding is not supported',\         ctl:ruleRemoveById=920272"

Проблема 4

Message: Warning. Matched phrase "var/log" at ARGS:html. [file "/etc/crs4/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf"] [line "116"] [id "930120"] [msg "OS File Access Attempt"] [data "Matched Data: var/log found within ARGS:html: ***** tail -f /var/log/apache2/modsec_audit.log
  • Здесь он уже умничает при анализе содержимого страницы 🙂 Выхода три: править сигнатуру, писать байпас либо не использовать ругательных слов в контенте. Первое отработает ровно до первого апдейта, третье, в нашем случае, не вариант. Пишем байпас.

SecRule ARGS:html "@rx (.*)" \         "id:001001002,\         phase:1,\         pass,\         nolog,\         msg:'Matched pattern bypass',\         ctl:ruleRemoveById=930120"

Проблема 5 — отсюда вытекающая

  • По факту мы с вами столкнулись с тем, что у нас огромное количество ложных срабатываний по причине используемой кодировки и по причине весьма специфичного контента. Что же делать? Глобально — либо понижать PL либо писать байпасы (выбор ваш). Плохо и то, и другое. Поэтому учитывайте специфику при подборе WAF.

  • Я уже был готов понижать PL и вовсю парсил логи, как пришло озарение 🙂 99% процентов срабатываний происходит в момент публикации контента из админки. Что это значит? Не более чем то, что мы можем привязаться к HTTP REQUEST_METHOD и сделать глобальный байпас всего.

  • Плюсы‑минусы? Плюсы — избавимся от срабатываний по кодировке и истерии по поводу скриптового контента. Минусы — на сайте есть формы поиска и авторизации, так что в них можно будет долбиться в обход политик.

  • Выбор в целом сводится к тому чтобы либо принять риски, либо писать 100 500 правил без гарантии отсутствия ложных срабатываний в будущем. Я эти риски принял. В целом можно прикрутить IPS с сигнатурами на POST метод и спать несколько спокойнее.

  • Мои кривые потуги в парсер если кому будет нужно

echo > /var/log/apache2/modsec_audit.log  cat /var/log/apache2/modsec_audit.log | grep -iE ".*notice.*|*.warning.*|.*error.*|.*critical.*" | perl -ne 'm/.*(ARGS:\w+)/ && print "$1\n"' | sort | uniq >> /var/tmp/args.txt  cat /var/log/apache2/modsec_audit.log | grep -iE ".*notice.*|*.warning.*|.*error.*|.*critical.*" | perl -ne 'm/ARGS:html.*(id "\d+")/ && print "$1\n"' | sort | uniq >> /var/tmp/args_html.txt  cat /var/tmp/args_html.txt | sed 's/id \"/ctl:ruleRemoveById=/g' | sed 's/\"/\,\\/g' >> /var/tmp/html_rules.txt
  • Правило для админки (даем два, ибо автосохранение реализовано через PUT)

SecRule REQUEST_METHOD "@rx (POST|PUT)" \         "id:001001001,\         phase:1,\         pass,\         nolog,\         msg:'Admin Panel WAF bypass',\         ctl:ruleEngine=Off"
  • В принципе в энтерпрайзе можно привязаться к блоку доверенных адресов и выше описанные проблемы уйдут. Например так.

SecRule REMOTE_ADDR "@ipMatchFromFile admin_panel_whitelist.txt" \         "id:001001001,\         phase:1,\         pass,\         log,\         msg:'Admin Panel WAF bypass - POST|PUT only',\         ctl:ruleEngine=Off,\         chain"  SecRule REQUEST_METHOD "@rx (POST|PUT)"
  • Еще раз проверяем логи на предмет срабатываний и закручиваем порог блокировки

nano -w /etc/crs4/crs-setup.conf
SecAction \     "id:900110,\     phase:1,\     pass,\     t:none,\     nolog,\     tag:'OWASP_CRS',\     ver:'OWASP_CRS/4.10.0',\     setvar:tx.inbound_anomaly_score_threshold=5,\     setvar:tx.outbound_anomaly_score_threshold=4"
  • Проверяем логи

Action: Intercepted (phase 2) Apache-Handler: application/x-httpd-php Stopwatch: 1737805433007092 8481 (- - -) Stopwatch2: 1737805433007092 8481; combined=5102, p1=911, p2=2835, p3=0, p4=0, p5=1355, sr=75, sw=1, l=0, gc=0 Response-Body-Transformed: Dechunked Producer: ModSecurity for Apache/2.9.7 (http://www.modsecurity.org/); OWASP_CRS/4.10.0. Server: Apache Engine-Mode: "ENABLED"
  • Если у вас стоит Fail2Ban, можно глянуть и туда

fail2ban-client status apache-modsecurity
Status for the jail: apache-modsecurity |- Filter |  |- Currently failed: 0 |  |- Total failed:     14 |  `- File list:        /var/log/apache2/error.log `- Actions    |- Currently banned: 1    |- Total banned:     6    `- Banned IP list:   193.41.206.98

Бонусные правила

  • Первым делом закроем передачу сигнатур сервера

nano -w /etc/apache2/mods-enabled/security2.conf
# Manipulating server signaturte ServerTokens Min SecServerSignature "null"
systemctl restart apache2
  • Проверяем

curl -i --header 'Host: null.somedomain.name' https://null.somedomain.name | less
Server: null
  • Далее подумаем, а нужно ли нам такое 🙂 Представим, что мы хостим некий контент на который прямых ссылок нет и не предвидится. Технически — можно пройтись каким‑нибудь кроулером по сайту в поисках HTTP 200 и дальше уже с этим работать (вопрос сложности и целесообразности оставим в стороне)

  • Что с этим можно сделать? Описанная ситуация предполагает некое количество HTTP 404 возвращенных клиенту до того как он получит HTTP 200, а с этим уже можно работать.

  • Напишем что‑то такое (улетаем в бан на день после 10ти HTTP 404)

SecRule REMOTE_ADDR "@ipMatchFromFile http_404_whitelist.txt" \         "id:001002001,\         phase:1,\         pass,\         nolog,\         msg:'IP Whitelisted for HTTP 404',\         ctl:ruleRemoveById=001002002,\         ctl:ruleRemoveById=001002003"  SecRule RESPONSE_STATUS "@streq 404" \         "id:001002002,\         phase:3,\         pass,\         setvar:IP.bad_http_request=+1,\         expirevar:IP.bad_http_request=86400,\         log,\         msg:'Page Not Found - HTTP 404 - Count %{IP.bad_http_request}'"  SecRule IP:BAD_HTTP_REQUEST "@gt 10" \         "id:001002003,\         phase:3,\         drop,\         log,\         msg:'Client Exceeded HTTP 404 Request Limit of 10 - Banned For a Day'"

Project Honey Pot

Что это за зверь такой? Не более чем база IP адресов ботов (впрочем, чуть более чем просто база, но об этом далее).

Установка «ловушки»

Несколько слов о том, зачем это все — в первую очередь позволяет собрать дополнительную статистику о разных ботах: роботах, кроулерах, спамерах (в том числе в камментах) и прочих харвестерах.

Работает все до нельзя просто:

  • Ханипот генерит скрипт, который потом можно захостить, активировать и тем самым получить свой ханипот

  • Дальше задача насытить сайт ссылками

  • Ну а дальше ждем пришествия роботов «прокликивающих» абсолютно все, в том числе нашу «ловушку»

  • Дальше данные о попаданце сливаются на ханипот и, собственно, к нам

  • Так же скрипт позволяет позволяет скормить ботам некоторое количество почтовых адресов и если на эти адреса пойдет спам рассылка — отправитель попадает в черный список

Надеюсь вы поняли

Приступим!

  • Регистрируемся на сайте и скачиваем скрипт для интеграции с сайтом (в нашем случае похопе).

  • Публикуем и выставляем разрешения

chown -R root:www-data /var/www/bookstack/public/hello.php chmod 644 /var/www/bookstack/public/hello.php
  • Вызываем браузером и активируем

  • Добавляем в footer ну или куда‑либо еще (только так, чтоп человекам не видно было)

<footer class="print-hidden">             <a href="https://null.somedomain.name/hello.php" target="_blank" rel="noopener"></a> </footer>
  • Альтернативный способ — инджектить напрямую из ModSecurity. Вот так

SecContentInjection On SecStreamOutBodyInspection On  SecRule RESPONSE_CONTENT_TYPE "@contains text/html" \         "id:001003001,\         phase:4,\         pass,\         nolog,\         chain"  SecRule STREAM_OUTPUT_BODY "@rsub s/<\/html>/<a href=\"https:\/\/null.somedomain.name\/hello.php\"><\/a><\/html>/"

Привязываем черные списки к ModSecurity

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

  • Генерируем ключ доступа к API на сайте проекта

  • Создаем правило (убедитесь что его айдишник отрабатывает первым)

nano -w /etc/crs4/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
# HTTPBL Check SecHttpBlKey API ключ  SecRule TX:REAL_IP|REMOTE_ADDR "@rbl dnsbl.httpbl.org" \         "id:001001001,\         phase:1,\         capture,\         block,\         log,\         msg:'HTTPBL Match of Client IP.',\         logdata:'%{tx.httpbl_msg}',\         setvar:tx.httpbl_msg=%{tx.0},\         chain"  SecRule TX:0 "threat score (\d+)" "chain,capture" SecRule TX:1 "@gt 20"

Заключение

На этом пока остановимся. Впереди Snort, DDoS Offloading, возможно SIEM (логи смотреть было бы крайне полезно).

Спасибо всем кто дочитал до конца! По традиции — кросс линк ProjectNull (сеошник во мне не умер 🙂

Буду рад ответить на вопросы по теме.


ссылка на оригинал статьи https://habr.com/ru/articles/876632/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *