Pull to refresh

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

Reading time9 min
Views7.4K

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


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

Спасибо что дочитали до конца. Надеюсь сообщество найдет эту статью полезной.
Tags:
Hubs:
Total votes 26: ↑24 and ↓2+22
Comments20

Articles