Использование XSLT для предотвращения XSS путем фильтрации пользовательского контента

Формулировка проблемы


Думаю никому из веб-разработчиков не нужно объяснять что такое XSS и чем он опасен. Но в то же время, многие сайты, такие как форумы, блоги, социальные сети и т.п., стремятся предоставить пользователю возможность вставлять на страницу свой контент. Для удобства неискушенных пользователей изобретаются WYSIWYG-редакторы, делающие процесс добавления красивого комментария легким и приятным. Но за всем этим фасадом скрывается угроза безопасности. Фактически любой WYSIWYG-редактор отправляет на сервер не просто текст комментария, он отправляет HTML-код. И даже если сам редактор не предусматривает использования опасных HTML-тегов (например <iframe>), то злоумышленника это не остановит — он может послать на сервер произвольный HTML-текст, который может представлять опастность для других посетителей сайта. Я думаю мало кому понравится получить в свой браузер что-то наподобие:
<script type="text/javascript">window.location="http://hardcoresex.com/";</script>

Таким образом, возникает проблема: полученный от пользователя HTML-код необходимо фильтровать. Но что значить «фильтровать»? Каким должен быть алгоритм фильтрации, чтобы не создавать необоснованных ограничений легальным пользователям, но в то же время сделать невозможной XSS-атаку со стороны злоумышленника? Увы, но HTML достаточно сложен, написать хороший парсер достаточно непросто, а любая ошибка в нем может привести к тому, что у злоумышленника появится лазейка через которую он сможет нанести удар.


Постановка задачи

Для начала я предлагаю сформулировать задачу формально. Итак, что должен сделать фильтр:
  1. Разобрать полученный HTML
  2. Применить к нему правила фильтрации, удалить или преобразовать небезопасные элементы
  3. Вернуть получившийся безопасный HTML для дальнейшей обработки

Для того чтобы разобрать HTML можно воспользоваться существующими библиотеками, например в PHP это можно сделать почти элементарно:
function htmlToDOM($html) {
  $doc=new DOMDocument();
  $doc->loadHTML($html);
  return $doc;
}

Но что делать с полученным DOM дальше? Как сформулировать какие правила нужно к нему применять? Мне хотелось получить такое решение, которое будет:
  1. Надежным. Под надежностью я понимаю прежде всего низкую вероятность ошибки в коде, которая может привести к пропуску опасных тегов, атрибутов или значений атрибутов.
  2. Универсальным. Под универсальностью я понимаю способность фильтровать HTML с произвольной степенью детальности: от «никаких тегов, только текст» до "<iframe> с атрибутом src, содержащим адрес youtube можно, остальные — нельзя" или «у тегов <p> атрибут style использовать можно, но из его значений убрать все что относится к свойствам кроме color и background-color»
  3. Легко конфигурируемым. Должна быть возможность описать эти правила понятным образом, причем простые правила должны описываться просто, без необходимости листать пять экранов галочек и выпадающих списков чтобы просто запретить все теги.

Поиск решения

Я возвращался к этой задаче время от времени, но удовлетворяющего меня решения не находил. Получалось либо очень сложно (как в настройке, так и в реализации), либо достаточно ограниченно. Решение возникло внезапно. Я обдумывал перспективы использования XSL-шаблонов для форматирования XML-контента, как меня осенило: ведь XSLT используется для трансформации документа, а значит может быть использован и для фильтрации нежелательных элементов тоже!

Решение действительно удовлетворяет сформулированным выше требованиям:
  1. Надежность. Всю работу выполняет XSLT-процессор, вероятность ошибки в котором достаточно низка, намного ниже чем в самописном решении
  2. Универсальность. С помощью XSLT можно сформулировать правила фильтрации с любой степенью детальности.
  3. Легкость конфигурации. Простое конфигурирование сводится к добавлению элементов в «белый» или «черный» список по имеющемуся шаблону. Сложные случаи, конечно, потребуют дополнительных описаний, но эта сложность возникает только если есть необходимость в тонкой настройке фильтрации. Еще одним преимуществом использования XSLT является то, что эта конфигурация может быть прочитана, понята и изменена любым разбирающимся в XSLT специалистом.

Создание фильтра с помощью XSLT


Реализация черного списка

Чтобы выяснить способна ли вообще эта идея функционировать я решил создать XSL-файл, описывающий простое копирование исходного документа в результирующий.
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
    <xsl:output method="xml" encoding="utf-8"/>
    
    <xsl:template match="@*|*">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>
    </xsl:template>
</xsl:stylesheet>

Как можно видеть, вся суть заключается в
    <xsl:template match="@*|*">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>
    </xsl:template>

Этот фрагмент отвечает за обработку всех элементов документа: тегов и их атрибутов. Текстовые элементы обрабатываются правилом по-умолчанию, которое просто копирует их в результирующий документ. Этим шаблоном обрабатываемый элемент также копируется в результирующий документ, а к его дочерним элементам и атрибутам рекурсивно применяются шаблоны (на самом деле все этот же универсальный шаблон). Таким образом, чтобы отфильтровать некоторые элементы нужно добавить шаблоны для них. Вот так, например, можно отфильтровать теги <script> вместе с их содержимым:
    <xsl:template match="script" />

Одна строчка! Если фильтровать содержимое не нужно, то можно использовать другой вариант, например после добавления следующего фрагмента все ссылки перестанут быть таковыми:
    <xsl:template match="a">
        <xsl:apply-templates />
    </xsl:template>

Этот фрагмент уберет теги <a>, но оставит их содержимое (которое, конечно, тоже будет повергнуто фильтрации). А вот так можно побороться с нежелательными атрибутами, например убрать у всех элементов атрибут style:
    <xsl:template match="@style" />

Как видите правила просты для написания и требуют минимальных комментариев даже для незнакомого с этой системой человека. Но запихивать все что нельзя в черный список неудобно. Черный список это скорее дополнительная возможность, но ни в коем случае не защита, так как появляются новые теги, новые атрибуты и необновленные вовремя правила фильтрации могут создать угрозу сайту. Поэтому для защиты от XSS я считаю более правильным применять «белый список» (запрещено все что явно не разрешено)

Реализация белого списка

Для реализации белого списка универсальное правило нужно переписать следующим образом:
    <xsl:template match="*">
        <xsl:apply-templates />
    </xsl:template>

    <xsl:template match="@*" />

Без дополнительных разрешающих правил оно оставит от HTML-кода только текстовые элементы, удалив все теги и их атрибуты (если не описать атрибуты отдельно — их зачения будут скопированы как текст). Чтобы разрешить, например, ссылки и картинки нужно добавить:
    <xsl:template match="a|img">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>
    </xsl:template>

Это правило разрешит сами теги, но не их атрибуты — они будут удалены, что сделает теги бесполезными. Это легко исправить:
    <xsl:template match="a/@href|img/@src">
        <xsl:copy />
    </xsl:template>

Это правило разрешает атрибут href у тега <a> и src у тега <img>. Поскольку у атрибутов дочерних элементов не бывает, то они просто копируются в результирующий документ. В этом правиле можно реализовать дополнительную проверку, например что ссылка ведет на объект по протоколу http:// или https:// (и таким образом избавиться от небезопасных протоколов, таких как data://):
    <xsl:template match="a[@href]">
        <xsl:variable name="target" select="@href" />
        <xsl:choose>
            <xsl:when test="starts-with($target, 'http://')">
                <xsl:copy>
                    <xsl:apply-templates select="@*|node()" />
                </xsl:copy>                
            </xsl:when>
            <xsl:otherwise>
                <xsl:apply-templates />
            </xsl:otherwise>
        </xsl:choose>
    </xsl:template>
    
    <xsl:template match="a/@href">
        <xsl:copy/>
    </xsl:template>

В этом правиле проверяется цель ссылки и в зависимости от этого принимается решение — копировать тег или нет. Теги <a> без атрибута href попадут под правило по-умолчанию и будут удалены. Аналогично можно сделать и с изображениями. Альтернативное решение — проверять значение атрибута в шаблоне атрибута, но это означает разнесение логики в два места:
    <xsl:template match="a[@href]">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>                
    </xsl:template>
    
    <xsl:template match="a/@href">
        <xsl:variable name="target" select="." />
        <xsl:if test="starts-with($target, 'http://')">
            <xsl:copy/>
        </xsl:if>
    </xsl:template>

Еще одна типичная задача — добавление ссылкам атрибута rel=«nofollow»:
    <xsl:template match="a[@href]">
        <xsl:copy>
            <xsl:attribute name="rel">nofollow</xsl:attribute>
            <xsl:apply-templates select="@*|node()" />
        </xsl:copy>                
    </xsl:template>

Ну и наконец, самый сложный случай: манипуляция значением атрибута. Продемонстрирую решение задачи, сформулированной в требованиях — разрешить атрибут style, убрать из его значения все кроме свойств color и background-color. Сначала создадим шаблон, который анализирует значение единичного свойства и либо разрешает его использовать, либо нет:
    <xsl:template name="filter-style-value">
        <xsl:param name="value" />
        <xsl:variable name="key" select="substring-before($value, ':')" />
        <xsl:if test="($key = 'color') or ($key = 'background-color')">
            <xsl:value-of select="$value" />
        </xsl:if>
    </xsl:template>

Теперь второй шаг: перебор всех свойств в значении и проверка каждого на допустимость:
    <xsl:template name="filter-style">
        <xsl:param name="value" />
        <xsl:param name="filtered" select="''" />
        
        <xsl:choose>
            <!-- Проверяем содержит ли строка точку с запятой -->
            <xsl:when test="contains($value, ';')">
                <!-- Разбиваем на первый элемент и все остальное -->
                <xsl:variable name="head" select="substring-before($value, ';')" />
                <xsl:variable name="tail" select="substring-after($value, ';')" />
                <!-- фильтруем первый элемент -->
                <xsl:variable name="fltr">
                    <xsl:call-template name="filter-style-value">
                        <xsl:with-param name="value" select="$head" />
                    </xsl:call-template>
                </xsl:variable>
                <!-- Делаем рекурсивный вызов -->   
                <xsl:call-template name="filter-style">
                    <xsl:with-param name="value" select="$tail" />
                    <xsl:with-param name="filtered">
                        <!-- Тут приходится решить нужно ли добавлять отфильтрованный элемент (и точку с запятой или нет) -->
                        <xsl:choose>
                            <xsl:when test="string-length($fltr) > 0">
                                <xsl:value-of select="concat($filtered, $fltr, ';')"/>
                            </xsl:when>
                            <xsl:otherwise>
                                <xsl:value-of select="$filtered" />                    
                            </xsl:otherwise>
                        </xsl:choose>                        
                    </xsl:with-param> 
                </xsl:call-template>
            </xsl:when>
            <!-- Не содержит точку с запятой -->
            <xsl:otherwise>
                <!-- Фильтруем -->
                <xsl:variable name="fltr">
                    <xsl:call-template name="filter-style-value">
                        <xsl:with-param name="value" select="$value" />
                    </xsl:call-template>
                </xsl:variable>
                <!-- Аналогично фрагменту выше -->
                <xsl:choose>
                    <xsl:when test="string-length($fltr) > 0">
                        <xsl:value-of select="concat($filtered, $fltr, ';')"/>
                    </xsl:when>
                    <xsl:otherwise>
                        <xsl:value-of select="$filtered" />                    
                    </xsl:otherwise>
                </xsl:choose>
            </xsl:otherwise>
        </xsl:choose>
    </xsl:template>

Это самый большой и сложный шаблон, но и задача нетривиальная. Его можно несколько упростить выделив повторяющийся код в еще один вспомогательный шаблон, но я не стал этого делать. Он прокомментирован, так что я думаю подробное описание его работы не требуется. Ну и последний шаблон, собственно отвечает за фильтрацию тегов:
    <xsl:template match="p[@style]">
        <xsl:variable name="style" select="@style" />
        <xsl:copy>
            <xsl:attribute name="style">
                <xsl:call-template name="filter-style">
                    <xsl:with-param name="value" select="@style"/>
                </xsl:call-template>
            </xsl:attribute>
            <xsl:apply-templates />
        </xsl:copy>
    </xsl:template>

Заключение


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

Спасибо что дочитали до конца. Надеюсь сообщество найдет эту статью полезной.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 20

    0
    Для данной задачи лучше не использовать чёрный список, т.к. слишком высока вероятность того, что что-то будет упущенно, а также потребуется переодически дополнять его.

    Также, иногда для атак используются незакрытые теги или теги с незакрытыми аттрибутами, которые исправляет уже браузер. Скорее всего, решение с loadHTML не сможет обработать такие ситуации.
    • НЛО прилетело и опубликовало эту надпись здесь
        0
        Тут скорее вопрос полезности комбинации множества решений, когда есть тот же HTML Purifier и аналоги.
        Статья описывает интересный подход к решению проблемы, который будет сложно применить на практике.
          0
          С моей точки зрения полезность предлагаемого решения в его универсальности — XSLT это универсальное средство, которое может быть применено на почти любом языке программирования, в почти любом фреймворке и не требует для понимания дополнительных знаний кроме знания XSLT.

          Можно сказать что по сравнению с HTML Purifier и аналогами это то же самое что и XSLT-шаблоны по сравнению со Smarty и аналогами.
        0
        Белые списки не могут описать все нюансы, например использование зловредного кода внутри разрешённого href. А раз так, и всё равно придётся использовать чёрные списки, то можно обойтись только ими, либо использовать и то, и другое.
          0
          Можно требовать чтобы href начинался с разрешенного списка префиксов, например http:// https:// ftp:// и // — это тоже вид белого списка. Да теряем, например, ссылки вида torrent:// но для ряда проектов это может быть некритично.
        +3
        Если вам на вход придет html-документ, не являющийся валидным xml, xslt шаблон не сможет его преобразовать, и это большая проблема.

        Наиболее частые проблемы, из-за которых html документ может оказаться невалидным xml (но при этом валидным html)
        • незакрытые теги переноса строки (<br> вместо <br />)
        • незакрытые изображения, input и т.д. (<img src=""> вместо <img src="" />)
        • аттрибуты без значений (<option selected /> вместо <option selected="selected" />)
        • неопределенные сущности (&nbsp; вместо &#160;)
        • символ <, который не открывает тег (например <script> if(a < b)</script> вместо <script> if(a &lt; b)</script>)


        Любая из этих проблем порушит ваш xslt-фильтр, при этом в принципе все они достаточно часто встречаются в коде, который генерируется визивиг-редакторами.
          0
          Привести невалидный документ к валидному не очень сложно, и после этого уже проходится xslt — Вам же в любом случае нужен валидный HTML на страничке, так?

          Я это делаю с помощью SAX парсера (а можно делать и кучей других уже готовых вариантов). принцип простой:
          1. получили открывающй элемент, положили в стэк
          2. встретили открывающий элемент, который не должен пресекаться с верхнем элементом стэка — закрыли его, убрали со стэка, положили в стэк новый
          3. иначе просто положили в стэк новый
          4. при встрече закрывающего элемента, не совпадающего с верхнем элементом стэка — пропустили его
          5. иначе убрали со стэка


          Вобщем както так )
            0
            Описанный принцип реализует только закрытие незакрытых элементов (2 проблемы из 5).

            Понятно, что привести HTML документ к xHTML в автоматическом режиме вполне возможно (есть даже уже написанные конвертеры, например на JS), вопрос в том, насколько оправдано поддерживать целый html -> xhtml конвертер ради такой простой задачи как фильтрация «опасных» тегов и аттрибутов в пользовательском вводе.

            На мой взгляд, для большинства прикладных задач — неоправданно.

            Вам же в любом случае нужен валидный HTML на страничке, так?

            Так я поэтому в первом комментарии и заметил что ни одна из обохзначенных мной проблем не делает html невалидным.
              0
              Я считаю что приводить пользовательский ввод к XHTML вполне оправданно. Это позволяет как минимум отдавать полностью корректный HTML в браузеры. Я знаю что стандарт HTML не требует закрытия всех тегов и обязательного использования кавычек для значений атрибутов, например, но такое преобразования позволяет гарантировать корректность отдаваемого посетителю документа и минимизировать возможные проблемы.

              Возможно я идеалист, но пока убедительных для меня доводов против использования XHTML я не встречал.
                0
                Ну, неужели я должен был полностью листинг привести? :-D
                Я показал пример как привести тэги — это самое сложное из всего перечисленного — все остальное реализуется регспами в пару строчек. Честно :)
                  0
                  Верю :-)

                  Но автор предложил решение лучше, без использования самописных парсеров и регулярок, пользоваться стандартными методами php (loadHtml)
              +2
              Насколько понимаю, в PHP loadHTML фиксит некорректный html и выдает на выходе корректное DOM-дерево к которому xslt применяется без проблем. По крайней мере распространенные проблемы (отсутствие закрывающих тегов и т.п.) он обрабатывает точно, хотя специально я его не ломал.

              В других языках программирования HTML тоже есть библиотеки, способные разобрать «поломанный» HTML и построить корректный DOM.

                0
                Да, вы правы.

                Упустил момент что шаблон применяется к вводу, который загружен с использованием loadHTML
              –1
              Имхо, тут имеет смысл двигаться от обратного: не описывать исключения, а обрабатывать лишь те элементы, которые нам нужны, посылая всё остальное либо в «мусор» либо в простой текстовый вывод.

              Например, у нас таким образом выводится весь контент (как пользовательский, так и редакционный): если в каком-то месте проекта не предполагается вывод ссылок, то неважно, кто их добавил — они будут проигнорированы, и выведется только содержание текста ссылки.

              Хотя, у всех задачи разные. И спасибо за rel=«nofollow» — отличное решение. Мне почему-то не приходило в голову, что можно внешним ссылкам ставить этот атрибут. :)
                –1
                Исключительно проблема движков, которые зачем–то используют HTML вместо bbCode, Creole и т. п.
                  0
                  Ну, например, diary.ru разрешает использовать html. Многие wysiwyg редакторы отдают на выходе html, а не bbcode. Поэтому я бы не сказал что это проблема только движков. Но и bbcode, к сожалению, не панацея — как он поможет если нужно гибко настроить разрешения — обычные пользователи могут использовать ограниченный набор тегов, модераторы — расширенный, администраторы — без ограничений? Все равно фильтрация в той или иной форме потребуется.
                    0
                    Вот в X-Wiki, которую я применяю, WYSIWIG тоже отдаёт HTML. Ну так его и конвертировать надо в нормальный формат. И конвертируется, и работает. И в IPB 3, и в X-Wiki.

                    Произвольный HTML и скрипты в X-Wiki тоже можно вставлять, но если его просто так вставить, он при преобразовании в X-Wiki прожуётся и выкинется. В самом редакторе сделаны крючки для плагинов так, чтобы блок, рендерящийся плагином, нельзя было редактировать в WYSIWIG на уровне гипертекста, можно было только редактировать его через меню WYSIWIG. Например, «содержание» или «сноски» — такие блоки, рендерящиеся плагинами, и редактировать их нельзя. HTML — тоже такой блок, и редактирование его напрямую невозможно, можно только в меню редактирования блока зайти и там писать чистый HTML.

                    bbCode, сколько я его видел, очень замечательно настраивается на права. Несанкционированные теги просто не обрабатываются при показе конечного текста. В некоторых разделах форума нельзя делать гиперссылки — типичный пример. То же и в X-Wiki. Некоторые блоки отображаются только, если страницу написал администратор.
                  0
                  Конструкцию choose-when-otherwise лучше по возможности не применять. На мой взгляд конечно. Т.к. элемент <xsl:template match="..."> уже является кейсом.
                    0
                    Да, наверное вы правы. У меня еще немного опыта с XSLT, поэтому кое-что сделал достаточно прямолинейно.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое