Создание безопасного 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 таки смогут сделать текст красным или зеленым, ничего страшного в этом не вижу (ну, или разобраться с ними потом можно будет). Главное, чтоб безопасно всё было.