Проблемы с валидацией
Проверка входных данных – безумно сложная задача. Мы стремимся проверить как формат, так и семантику. Позвольте мне рассказать вам историю Йоды, Консервативного Библиотекаря Времени. Некоторая программа, записывавшая только дни рождения, установила время на 00:00 – хороший, консервативный выбор. 13 апреля 1941 года часы в Саскачеване, Канада, «прыгнули вперед» в полночь в рамках перехода на летнее время. А значит, ни у кого не было возможности появиться на свет в полночь того дня. Таким образом, функции валидации широко используемой библиотеки Joda-Time не могли принять дату рождения пациента при назначении лабораторных анализов [Lyon, 2020].
Что делать с таким частично недопустимым вводом? Если ваш ответ начинается со слова «очевидно», пожалуйста, сделайте глубокий вдох и подумайте, что может пойти не так. Одним из распространенных шаблонов является попытка починить данные или санировать их, что мы рассмотрим в следующем разделе. Другим вариантом может быть использование даты без указания времени, что имеет смысл, когда пациент взрослый, но, возможно, точный возраст имеет решающее значение в интенсивной терапии в акушерстве.
Еще одна проблема с валидацией возникает, когда таблицы валидации отстают от реальности. Возьмем для примера список планет:
Planets = [Mercury Venus Earth Mars Jupiter Saturn
Uranus Neptune Pluto]
Когда Плутон был «понижен в должности», ранее правильный код стал неверным. Более «земная» версия проблемы возникает, когда строится новое муниципальное образование: требуется время, чтобы распространились названия и географические координаты новых улиц, и некоторые компании могут не справиться с предоставлением услуг, пока их базы данных не будут обновлены. Что еще хуже, после обновления таблиц валидации входные данные, которые раньше были приемлемыми, могут не работать.
Тщательное документирование того, что именно проверяется, помогает нам писать код, который решает эти проблемы ради надежного функционирования. Тщательное определение контрактов и модульных тестов помогает гарантировать, что наш код не удивит нас.
Санация
Увидев испорченные продукты в продуктовом магазине, большинство из нас укажут на них сотруднику, чтобы тот их убрал. Вы не возьмете их в руки, не попросите посчитать и не попытаетесь ими воспользоваться. Плохие входные данные подобны испорченным продуктам. Вы не должны пытаться санировать (очищать) их. Вы должны отклонить их, объяснить свой отказ и перейти к следующему запросу. В противном случае вы рискуете превратить входные данные во что-то опасное.
Примеры неудачной санации легко найти. Пожалуй, самой известной из них оказалась функция PHP Magic Quotes. Сбои были сложными и требовали некоторого понимания функции, поэтому давайте возьмем немного надуманный пример. Предположим, что вы отклоняете любой ввод со строкой script, а затем преобразуете его к виду «Все заглавные». (Возможно, вы даже не думаете о том, что верхний регистр – это санация.) Но если входные данные содержат scrıpt, вы получите SCRIPT, как видно из таблицы 8.1.
Таблица 8.1
Сюрприз! Если вы переведете в верхний регистр символ ı (U+0131, строчная буква i без точки), в некоторых условиях вы получите I (U+0049, распространенная английская заглавная буква I.) Очистив входные данные, вы нарушили собственную проверку.
Целью санации часто является работа с данными, которые не проходят явную проверку. Если необходимо проанализировать данные, которые не прошли проверку, можно отбросить некоторые из них, а также можно вызвать более изолированный синтаксический анализатор, чтобы попытаться разобраться в них.
Канонизация
Существует большое разнообразие допустимых, пригодных для использования интерпретаций любого набора битов. Принято стремиться к «каноническому» представлению и верить, что это решит ваши проблемы с синтаксическим анализом. И хотя канонизация полезна, потому что упрощает проверку, это не панацея.
Типичным примером канонизации является путь в Unix. Мы разбираем символические ссылки, заменяем начальный ~ домашним каталогом пользователя и, увидев «..», удаляем его и предыдущее имя каталога. В идеале это выглядит как вывод realpath(), и мы можем проверить его и передать open(). Эта проверка может гарантировать, например, что он начинается с /usr/local/include, что хорошо для open, но если мы передадим его другой программе, особенно той, которая выполняет setuid, то этой другой программе может потребоваться выполнить другие проверки.
По мере усложнения форматов определение канонического языка может усложниться. Даты являются (кхм) каноническим примером. Но давайте посмотрим на URL-адреса. URL-кодировка символа % – это %25. Если я выполню поиск в Google по запросу «%25», это будет закодировано как %2525 [Nadel, 2021]. Когда я канонизирую эту строку, возвращаю ли я % или %25? То есть должен ли я гарантировать, что вывод канонической функции, переданный самой себе, возвращает идентичную строку? Мы ожидаем, что он вернет что-то однозначное и что двусмысленность не будет чрезмерно ограничивать использование. Вы можете утверждать, что мы используем неоднозначность в кодировке, и мы можем решить проблему, если будем более конкретны. И хотя это ключевой момент этой главы, различные кодировки продолжают сбивать с толку реальных дизайнеров систем.
Когда формат имеет уровни кодирования, он быстро усложняется. Вы хотите перевести все, что начинается с маркера процента (%), в их эквиваленты ASCII перед декодированием UTF-8.
Или, может быть, все наоборот? Я не лукавлю и не уклончив, я, честно говоря, не знаю, и ссылки, которые я проверил, не дают мне простого ответа [OWASP, 2013; Zalewski, 2011].
Белые списки и черные списки
Списки разрешений и запретов – это способы ограничения ввода. Их также называют белыми и черными списками, но мир технологий неуклонно движется в сторону более четкого и инклюзивного языка. Например, является ли черный список (blacklist) чем-то похожим на бизнес, который находится «в плюсе» (in the black), или это негативная вещь [NIST, 2021]? Название «списки запретов» может вводить в заблуждение. Возможно, потому что мы думаем о нападении и говорим: «Мы должны запретить это!» Список запретов растет до тех пор, пока мы уже не можем придумать, какое бы еще зло запретить, и надеемся, что наши злоумышленники остановятся на том же этапе. Таким образом, списки разрешений гораздо более эффективны с точки зрения безопасности, потому что они выходят из строя относительно безопасно.
Чтобы объяснить это, предположим, что у нас есть список символов, которые мы не будем принимать во входных данных, предназначенных для вывода HTML (то есть список запрещенных символов HTML):
evil = [«’;`&<>]
while (c = input[i]) {
if { c ~= /evil/ then i++;}
else { output += c; i++;}}
Подумайте о том, что может пойти не так. (Подсказка 1: чего не хватает в списке? Подсказка 2: как это обобщается?) В отличие от этого, белый список выглядит следующим образом:
acceptable = [A-Za-z0-9]
while (c = input[i]) {
if { c ~= /acceptable/ then output += c }
i++;}
Вы можете использовать шаблон белого списка в валидаторе. В идеале белый список избыточен из соображений надежности, так ваш Recognizer уже обеспечил соответствие грамматике. Хороший выбор того, что разрешено, должен быть одновременно ограниченным и чувствительным. Не будьте наивны в этом отношении. Ранние попытки предотвратить внедрение SQL-кода привели к тому, что люди по имени О’Коннор не могли войти в систему или было невозможно получить адреса электронной почты со знаками +. Особенно если текст содержит имена, он может выходить за рамки базового набора символов A – Z/a – z.
Трудности, связанные с безопасным разбором произвольных протоколов с помощью созданного вручную кода, могут показаться похожими на подъем истребителя X-wing из болота. К счастью, помощь доступна. Она принимает форму использования более безопасного кода, применяя к вашему менее безопасному коду статический анализ и сочетая его с защитой, которая усложняет эксплуатацию уязвимостей. Наконец, вы можете использовать методы тестирования, включая фаззинг, для обнаружения проблем в скомпилированном коде.
В целом, если вы пишете на C или C++, существует множество подводных камней в том числе связанных с безопасностью. Книги по защите, такие как Effective C («Эффективный С») [Seacord, 2019], шире, чем «Безопасное программирование на C и C++» (Secure Coding in C and C++) [Seacord, 2005], которая остается отличным глубоким справочником.
Более безопасные языки и библиотеки
Многие современные языки, в том числе Python и Go, были разработаны для защиты разработчиков от некоторых недостатков, присущих C, C++ и даже Java. Популярные улучшения включают в себя безопасность типов и более безопасную работу со строками. Не все современные языки сделали одинаковый выбор, и заметьте, что я использую термин «более безопасный», а не «безопасный». Вы можете написать плохой код на любом языке.
Выбор более безопасного языка для новых проектов может окупиться сторицей. Первоначальная работа по изучению нового языка приводит к меньшему количеству ошибок в будущем. Конечно, трудно переписать целые проекты, но можно переписать части системы, такие как парсеры.
Существуют библиотеки, предназначенные для того, чтобы сделать синтаксический анализ более безопасным. Например, проект Microsoft Everparse берет C-подобные определения, создает F*-код, формально доказывает его безопасность, а затем компилирует его в C, не теряя доказуемости. (F* – это язык программирования, предназначенный для формальной верификации.) Everparse был использован для создания верифицированных версий таких протоколов, как TLS и протокол обмена сообщениями Signal.