Использование 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 содержит неточности, он предназначен исключительно для демонстрации концепции, это не тот код, который можно применять в продакшене. Я также еще не проектировал систему в целом, но очевидно что она будет сохранять результат фильтрации, таким образом преобразование будет выполняться один раз — при добавлении контента. Выводится на страницу будет уже безопасная версия.

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

Комментарии 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, поэтому кое-что сделал достаточно прямолинейно.

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

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