HTMLPurifier и контент тега PRE

HTMLPurifier

Недавно мы познакомились с использованием HTMLPurifier. Этот компонент позволяет отфильтровать вредные элементы из HTML кода, обработать ссылки, закрыть незакрытые теги. Большой список возможностей позволяет использовать его для фильтрации полученного от пользователя контента. Вместо использования BBCode мы попробуем доработать HTMLPurifier для удобной работы тега <pre> в комментариях пользователей.

Непосредственно в любых проектах данную библиотеку можно использовать так:

require_once '/path/to/HTMLPurifier.auto.php';
 
$config = HTMLPurifier_Config::createDefault();
 
$config->set('AutoFormat', 'AutoParagraph', true);
$config->set('HTML', 'Allowed', 'p,ul,li,b,i,a[href],pre');
$config->set('AutoFormat', 'Linkify', true);
$config->set('HTML', 'Nofollow', true);
$config->set('Core', 'EscapeInvalidTags', true);
 
$purifier = new HTMLPurifier($config);
$clean_html = $purifier->purify($dirty_html);

В проектах на Yii можно воспользоваться встроенным классом CHtmlPurifier. Процесс фильтрации достаточно сильно нагружает систему и долго обрабатывает насыщенные тегами тексты, поэтому его лучше не использовать для фильтрации в представлении непосредственно при выводе текста. Лучше фильтровать текст из поля $text и сохранять его в поле $purified_text таблицы нашей модели при каждом сохранении записи:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    ...
 
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
        return $p->purify($text);
    }
}

Это модель нашего комментария. В нём мы разрешим комментаторам использовать некоторые теги.

Данные параметры передают компоненту HtmlPurifier разрешённый список тегов, указывают на необходимость преобразовывать переносы строки в параграфы и URL адреса в ссылки, закрывать забытые комментатором теги, а также добавлять атрибут rel="nofollow" во все внешние ссылки.

Теперь если пользователь напишет комментарий:

Вступление в комментарий

Завершение <b>комментария

он сохранится в поле $text, а в поле $purified_text запишется обработанный результат:

<p>Вступление в комментарий</p>
 
<p>Завершение <b>комментария</b></p>

Его и нужно будет выводить в представлении:

<?php echo $model->purified_text; ?>

Теперь перейдём именно к проблеме.

Фильтрация контента тегов pre и code

Предположим, что на блоге о программировании мы разрешили комментаторам использовать тег <pre>. Пусть комментатор приводит в комментарии фрагмент HTML кода, обрамив его блоком <pre></pre>:

Вступление в комментарий

<pre>
<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>
</pre>

Завершение <b>комментария

Мы видим два предложения и блок исходного кода. Также комментатор забыл закрыть тег <b>.

Он ожидает увидеть свой код в первозданном виде:

Вступление в комментарий

<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>

Завершение комментария

То есть в поле $purified_text содержимое тега <pre> должно быть просто переконвертировано в HTML сущности:

<p>Вступление в комментарий</p>
<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>
<p>Завершение <b>комментария</b></p>

Но вместо этого кода там будет совершенно другой результат:

<p>Вступление в комментарий</p>
<pre>
    <ul>
        <li><a href="#">Пункт 1</a></li>
        <li><a href="#">Пункт 2</a></li>
    </ul>
</pre>
<p>Завершение <b>комментария</b></p>

HTMLPurifier не «знает» об особенностях тегов <pre> и <code> и рассматривает их как и все остальные. Следовательно он «зашёл» внутрь и отфильтровал их контент, а именно удалил классы, теги <div> и <span>.

Дабы избежать такого поведения, нужно попросить пользователей кодировать все спецсимволы в своих комментариях, то есть записывать комментарии так:

Вступление в комментарий

<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#"&gt;Пункт 1&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт 2&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>

Завершение <b>комментария

Но это не очень удобно для пользователей.

Мы пришли к выводу, что необходимо не впускать HTMLPurifier внутрь тегов <pre>. Вместо этого необходимо конвертировать их контент с использованием функции htmspecialchars().

Способы решения проблемы

Проблема, на самом деле, не нова, и в сети можно найти несколько путей её решения.

Воспользуемся простым способом:

$string = str_replace(array("<pre>", "</pre>"), array("<pre><![CDATA[", "]]></pre>"), $string);

То есть перед запуском процесса фильтрации необходимо обрамить нужный участок кода конструкцией <![CDATA[...]]>:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = str_replace(array("<pre>", "</pre>"), array("<pre><![CDATA[", "]]></pre>"), $text);
 
        $text = $p->purify(trim($text));
        return $text;
    }    
}

и код комментария

Вступление в комментарий

<pre>
<div class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</div>
</pre>

Завершение <b>комментария

внутри тега <pre> будет автоматически переконвертирован в HTML сущности:

<p>Вступление в комментарий</p>
<pre>
&lt;div class="menu"&gt;
    &lt;ul&gt;
        &lt;li class="active"&gt;&lt;a href="#">Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href="#"&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/div&gt;
</pre>
<p>Завершение <b>комментария</b></p>

Вроде теперь наш код работает верно. Но что если внутри кода, скопированного комментатором, будет свой элемент <![CDATA[...]]>?

Протестируем работу на таком примере:

<pre>
<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });
]]>
</script>
<section class="content">
    <!-- // content -->
</section>
</pre>

После сохранения наш комментарий выведется так:

<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });


<section class="content"></section>
]]>

Мы видим, что HTMLPurifier некорректно обработал вложенность элементов <![CDATA[...]]>. Наш внешний элемент <![CDATA[ закрылся в середине текста первым же внутренним ]]>.

Воспользуемся первым и более сложным способом:

$text = preg_replace_callback('/<pre>(.*)<\/pre>/ismU', 'store', $text);

Функция preg_replace_callback с помощью регулярного выражения для каждого тега <pre> вызовет функцию store($matches) и заменит фрагмент текста на её результат.

Передадим этой конструкции наш метод encodePreContent:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    protected function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = preg_replace_callback('|<pre>(.*)</pre>|ismU', array($this, 'encodePreContent'), $text);
 
        $text = $p->purify(trim($text));
 
        return $text;
    } 
 
    private function encodePreContent($matches)
    {
        return '<pre>' . CHtml::encode($matches[1]) . '<pre>';
    }
}

В методе мы просто перекодируем спецсимволы с помощью вызова метода CHtml::encode. Теперь комментарий будет выводиться именно так, как нам нужно:

<nav class="menu">
    <ul>
        <li class="active"><a href="#">Пункт <span>1</span></a></li>
        <li><a href="#">Пункт <span>2</span></a></li>
    </ul>
</nav>
<script type="text/javascript">
<![CDATA[
    $('.menu > ul > li').click(function(){
        $(this).find('ul').slideToggle();
    });
]]>
</script>
<section class="content">
    <!-- // content -->
</section>

И в базе данных весь контент будет переконвертирован:

<pre>
&lt;nav class=&quot;menu&quot;&gt;
    &lt;ul&gt;
        &lt;li class=&quot;active&quot;&gt;&lt;a href=&quot;#&quot;&gt;Пункт &lt;span&gt;1&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;#&quot;&gt;Пункт &lt;span&gt;2&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
&lt;/nav&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
&lt;![CDATA[
    $(&#039;.menu &gt; ul &gt; li&#039;).click(function(){
        $(this).find(&#039;ul&#039;).slideToggle();
    });
]]&gt;
&lt;/script&gt;
&lt;section class=&quot;content&quot;&gt;
    &lt;!-- // content --&gt;
&lt;/section&gt;
</pre>

Но и это ещё не всё. Мы среди настроек HTMLPurifier указали

'AutoFormat.Linkify' => true,

Этот параметр включает преобразование URL адресов в тексте в ссылки. Это значит, что если в тексте внутри тега <pre> окажутся URL адреса, то они всё равно будут преобразованы в активные ссылки. Аналогично могут произойти какие-либо другие казусы после прохождения перекодированного контента через HTMLPurifier.

Итак, чтобы HTMLPurifier ни при каких обстоятельствах не смог повредить наш код, можно поступить так:

  • Вырезать контент из всех тегов <pre> (заменив на уникальные маркеры);
  • Произвести фильтрацию оставшегося текста с помощью HTMLPurifier;
  • Вернуть контент на место, пропустив через CHtml::encode.

Чтобы не было коллизий, в качестве маркеров будем использовать, например, хэши md5:

/*
 * $param string $text;
 * $param string $purified_text;
 */
class Comment extends CActiveRecord
{
    protected function beforeSave()
    {
        if (parent::beforeSave()){
            $this->purified_text = $this->purify($this->text);
            return true;
        }
        return false;
    }
 
    private function purify($text)
    {
        $p = new CHtmlPurifier;
 
        $p->options = array(
            'AutoFormat.AutoParagraph' => true,
            'HTML.Allowed' => 'p,ul,li,b,i,a[href],pre',
            'AutoFormat.Linkify' => true,
            'HTML.Nofollow' => true,
            'Core.EscapeInvalidTags' => true,
        );
 
        $text = preg_replace_callback('|<pre([^>]*)>(.*)</pre>|ismU', array($this, 'storePreContent'), $text);
 
        $text = $p->purify(trim($text));
 
        $text = preg_replace_callback('|<pre([^>]*)>(.*)</pre>|ismU', array($this, 'resumePreContent'), $text);
 
        return $text;
    } 
 
    private $_preContents = array();
 
    private function storePreContent($matches)
    {
        $attrs = $matches[1];
        $content = $matches[2];
        do {
            $id = md5(rand(0, 100000));
        } while (isset($this->_preContents[$id]));
        $this->_preContents[$id] = $content;
        return '<pre' . $attrs . '>' . $id . '</pre>';
    }
 
    private  function resumePreContent($matches)
    {
        $attrs = $matches[1];
        $id = $matches[2];
        $content = isset($this->_preContents[$id]) ? CHtml::encode($this->_preContents[$id]) : '';
        return '<pre' . $attrs . '>' . $content . '</pre>';
    }    
}

Используя данный подход, мы полностью заменили обработку контента тега <pre> на ручную и исключили его модификацию парсером конпонента.

Этот функционал для тегов <pre> и <code> теперь добавлен в DPurifyTextBehavior. Для активации необходимо в параметры поведения добавить строку

'encodePreContent'=>true,

По умолчанию этот параметр отключён.

На этом всё. Данный метод корректно справится с блоками <![CDATA[...]]> в тексте. Проблемы могут возникнуть только с использованием вложенного тега <pre>, но частота использования этого тега в исходниках в отличие от блока CDATA крайне мала.

Комментарии

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>