Red Hot Chili Python

Создание безопасного WYSIWYG-редактора

«  Опыт использования MongoDB для подсчета статистики   ::   Contents

Создание безопасного WYSIWYG-редактора

Note

На храбре мой топик скрыли, потому что я внизу указал, что у меня карма < 20 потому не могу написать топик-ссылку. В результате забанили меня на месяц (а топик унесли в черновики). Спасибо mickolka, что запостил снова.

Note

Вопрос с хабра: FuN_ViT: а санитайзер умеет закрывать непарные теги?

Ответ: санитайзер – нет (он только санитайзит), а вот построитель дерева – вполне. Ну, короче говоря,

def test_unclosed(self):
    unsafe = u"""<a><b>evil</a>"""
    safe = u'<a><b>evil</b></a>'
    self.assertEqual(sanitize_html(unsafe), safe)

Note

Комментарий с хабра: AlienZzzz: я поменял, теперь все работают на wymeditor, все довольны очень.

Ответ: Не очень понял, если честно. Если имеется в виду, что он удобнее CKEditor – ок, каждому свое. Но он генерирует HTML (причем опасный), который тоже нужно очищать (если вы об этом). Успешно на страничке с демо нажал “HTML” и написал <script>alert(‘hi’);</script> и увидел свой алерт.

На наших сайтах (Prom.ua, Tiu.ru, Satu.kz, Deal.by и еще различные сайты в других странах) встала необходимость поменять редактор с WMD на какой-нибудь WYSIWYG. То есть до этого люди писали текст в Ъ-Markdown синтаксисе, что, конечно, греет душу программистам (если б еще LaTeX), тем не менее совершенно неудобно для простого человека, продающего электродвигатели для деревообрабатывающих станков.

Причина использования Markdown нами и множеством других веб-сайтов вполне очевидна – Markdown безопасен и вы ценой малой крови можете дать какую-никакую разметку вашим пользователям и возможность вставлять картинки и видео, при этом не опасаясь, что кто-то вставит <script> в ваш html.

Собственно, решение

Недолго поискав я обнаружил прекрасную библиотеку html5lib, в которой присутствует функция санитара, который очищает ваш HTML от всего небезопасного, что только существует в природе HTML. Конечно, гарантии теоретически вам никто не даст (кто ж знает абсолютно все извращения всех браузеров и ИЕ вместе взятых?), но каждый разрешенный элемент и аттрибут тщательно продуман и при помощи крауд-сорсинга (все дружно на вики-страничке составили список хороших и плохих тегов и аттрибутов и почитали работу других) составлен.

API санитара прост и присутствует в небольшом руководстве по html5lib:

import html5lib
from html5lib import sanitizer

p = html5lib.HTMLParser(tokenizer=sanitizer.HTMLSanitizer)
p.parse("<script>alert('foo');</script>")

Существует очистка во время парсинга html и во время его отображения, но нам нужна только первая.

WYSIWYG

В качестве WYSIWYG был взят CKEditor как простой и легко настраиваемый редактор (и проверенный временем). Лёгким движением руки настраиваем этот редактор при помощи config.js, оставив только необходимые кнопки на панели (возможно еще поменяются):

CKEDITOR.editorConfig = function( config ) {
    config.extraPlugins = 'youtube';
    config.toolbar = [[ 'Format', 'Bold', 'Italic', '-',
                        'Link', 'Unlink', 'Anchor', 'Image', 'Youtube',
                        'NumberedList', 'BulletedList', 'HorizontalRule',
                        'Table', 'Maximize' ]];
    config.height = '500px';
};

Плагин YouTube

Вообще, самой сложной задачей во всём этом оказалось требование возможности вставить youtube-видео в редакторе, а также настроить санитара так, чтоб тот не очищал этот iframe (ютубовский) как неугодный.

Итак, на просторах интернета я нашел плагин для CKEditor, добавляющий кнопку вставки видео с ютуба. Я уже не помню, что мне там не понравилось в коде (по-моему, функция получения youtube video id из строки адреса), потому вот мой, новый youtube.js:

(function() {
    CKEDITOR.dialog.add(
        'youtube',
        function(editor) {
            return {
                title: editor.lang.youtube.title,
                minWidth: CKEDITOR.env.ie && CKEDITOR.env.quirks ? 368 : 350,
                minHeight: 240,
                onShow: function() {
                    this.getContentElement('general','content').getInputElement().setValue('')
                },
                onOk: function() {
                    var re = /v=([a-zA-Z0-9]+)/;
                    var val = this.getContentElement('general','content').getInputElement().getValue();
                    var matches = re.exec(val);
                    // val = val.replace("watch\?v\=", "v\/");
                    if (matches != null) {
                        var youtube_id = matches[1];
                        var text = (
                            '<iframe class="youtube-player" frameborder="0" type="text/html" title="YouTube video player" width="480" height="390" src="http://www.youtube.com/embed/'
                                + youtube_id
                                +'?rel=0"></iframe>');
                        this.getParentEditor().insertHtml(text)
                    } else {
                        alert('Invalid youtube URL. Example is: http://www.youtube.com/watch?v=XSGBVzeBUbk');
                    };
                },
                contents: [{
                    label: editor.lang.common.generalTab,
                    id: 'general',
                    elements: [{
                        type: 'html',
                        id: 'pasteMsg',
                        html: '<div style="white-space:normal;width:500px;"><img style="margin:5px auto;" src="'
                            + CKEDITOR.getUrl(CKEDITOR.plugins.getPath('youtube')
                                              + 'images/youtube_large.png')
                            + '"><br />'+editor.lang.youtube.pasteMsg
                            + '</div>'
                    }, {
                        type: 'html',
                        id: 'content',
                        style: 'width:340px;height:90px',
                        html: '<input size="100" style="' + 'border:1px solid black;' + 'background:white">',
                        focus: function() {
                            this.getElement().focus()
                        }
                    }]
                }]
            }
        }
    )
})();

Итак, с редактором разобрались, осталась серверная часть.

html5lib, санитар и iframe

Проходясь по элементам (при парсинге) html5lib принимает параметром ваш собственный tokenizer, который может быть html5lib.HTMLSanitizer. Так как мы хотим его видоизменить, сделаем свой:

class HTMLSanitizerMixin(sanitizer.HTMLSanitizerMixin):

    def sanitize_token(self, token):
        from html5lib.constants import tokenTypes

        def make_safe_youtube_url(url):
            """Returns safe youtube url or None (if it's not youtube URL)"""
            import re

            def youtube_embed_url_from_id(youtube_id):
                return u"http://www.youtube.com/embed/%s?rel=0" % youtube_id

            youtube_id_re = re.compile(r'youtube.com/embed/(?P<youtube_id>[a-zA-Z0-9]+)')
            match_obj = youtube_id_re.search(url)
            if match_obj:
                groups = match_obj.groupdict()
                youtube_id = groups.get('youtube_id')
                if youtube_id is not None:
                    return youtube_embed_url_from_id(youtube_id)
            return None

        DEFAULT_WIDTH = 560
        DEFAULT_HEIGHT = 349

        if token.get('name') == 'iframe':
            if token.get('type') == tokenTypes["StartTag"]:
                data = token.get('data')
                data_dict = dict(data)
                url = data_dict.get('src', u'')
                try:
                    width, height = (int(data_dict.get('width', DEFAULT_WIDTH)),
                                     int(data_dict.get('height', DEFAULT_HEIGHT)))
                except Exception:
                    width, height = (DEFAULT_WIDTH, DEFAULT_HEIGHT)
                safe_youtube_url = make_safe_youtube_url(url)
                if safe_youtube_url:
                    self.next_iframe_safe = True
                    ret_val = (
                        {'selfClosing': False,
                         'data': [[u'width', unicode(width)],
                                  [u'height', unicode(height)],
                                  [u'src', safe_youtube_url],
                                  [u'frameborder', u'0'],
                                  [u'allowfullscreen', '']],
                         'type': 3,
                         'name': u'iframe',
                         'selfClosingAcknowledged': False})
                    return ret_val
            if token.get('type') == tokenTypes["EndTag"] and \
               self.next_iframe_safe is True:
                self.next_iframe_safe = False
                return token
        elif self.next_iframe_safe is True:
            return None

        ret_val = super(HTMLSanitizerMixin, self).sanitize_token(token)

        return ret_val

class HTMLSanitizer(tokenizer.HTMLTokenizer, HTMLSanitizerMixin):
    def __init__(self, *args, **kw):
        self.next_iframe_safe = False
        super(HTMLSanitizer, self).__init__(*args, **kw)

    def __iter__(self):
        for token in tokenizer.HTMLTokenizer.__iter__(self):
            token = self.sanitize_token(token)
            if token:
                yield token

Итак, как видим, в функцию sanitize_token подается параметром token, в котором содержится информация, открывающий это тег или закрывающий (или и то и другое), его название, аттрибуты и т.д.

При встрече открывающего тега iframe мы посмотрим его параметр src и в случае удостоверения, что внутри содержится ссылка на youtube – достанем youtube id (безопасный, по крайней мере состоящий только из [a-zA-Z0-9]) и сформируем новый iframe, в котором из пользосвательских данных возможны только youtube id и высота/ширина (которые мы жестким int() ограничим).

Далее была проблема, что делать со внутренним содержанием этого самого iframe, т.к. по-идее ничего опасного оно из себя не представляет и воспринимается браузерами как текст, но кто-то на канале #whatwg (где сидят разработчики html5lib) предложил просто пропускать все данные, пока не встретим </iframe>. Я так и сделал.

Последнее, что стоит отметить, – странность, почему

self.next_iframe_safe = False

я делаю в конструкторе класса HTMLSanitizer, а работаю с этой переменной в классе HTMLSanitizerMixin.

Проблема в том, что класс html5lib.tokenizer.HTMLTokenizer в свое время был сделан old-style классом, потому что дал прирост 15-20% производительности. По иронии судьбы вот таким был commit-message:

Changed HTMLTokenizer and HTMLInputStream to be old-style classes (improves overall parse performance by 15-20%). (It seems unlikely that anyone will be depending on the new-style class semantics here.)

Потому цепочка super() до конструктора вашего Mixin просто не доходит)

Измеряв текущий html5lib на большом файле я не заметил особого разницы в скорости, потому создал баг с патчем, который возвращает новостиль этому классу. Вроде разработчики не были против.

Потенциал

В HTMLSanitizerMixin также можно добавить аттрибут allowed_attributes и allowed_css_properties (ну, и еще несколько), в которых указать, какие конкретно элементы считать хорошими (а точнее, какие “не отбрасывать”).

Заключение

Редактор пока еще не в продакшне, потому замечаниям буду особенно рад. Также хочу заметить, что я не ограничивал конечный HTML тем, что позволяет наш редактор специально, пусть особые умельцы и кулхакеры HTML таки смогут сделать текст красным или зеленым, ничего страшного в этом не вижу (ну, или разобраться с ними потом можно будет). Главное, чтоб безопасно всё было.

«  Опыт использования MongoDB для подсчета статистики   ::   Contents

blog comments powered by Disqus