Класс CommentHtmlFilter для фильтрации пользовательских комментариев в формате Html

Категория: Web
Опубликовано 23.09.2012 04:00

Когда я добавлял возможность комментирования на сайте, то решил использовать комментарии в формате html вместо широко распространенного bbcode. Одна из причин, это нормальный WYSIWYG-редактор. Но на этом пути сразу же встает большая проблема с безопасностью: если не фильтровать пользовательский ввод, то тут же можно наполучать в комменты <script>alert('you hacked')</script> и прочих веселых вещей. Даже просто коммент с незакрытым тегом и здравствуй расползание верстки на странице. Поэтому фильтровать надо обязательно.

Погуглив в поисках готовых решений ничего дельного не нашел и просто написал свой класс, который решил выложить и описать, как он работает.

Класс CommentHtmlFilter:

using System;
using System.IO;
using System.Linq;
using System.Text;
using HtmlAgilityPack;
 
namespace JonxxxMVC.Code
{
    public class CommentHtmlFilter
    {
        #region массивы тегов
        private static readonly string[] StopList =
            {
                "<iframe", "<script", "<noindex", "<flash", "<object", "<frame", "<link",
                "<style", "<meta", "<frameset", "<applet", "<embed", "<form", "<input"
            };
 
        private static readonly string[] TrustTags =
            {
                "li", "ul", "a", "img", "ol", "p", "strong", "em", "u", "strike",
                "blockquote", "hr", "span"
            };
 
        private static readonly string[] TrustAttr = { "style", "height", "alt", "src", "title", "width", "href" };
        #endregion
 
 
        private readonly HtmlDocument _documentIn;
        private readonly HtmlDocument _documentOut;
        public bool ParsingError { get; private set; }
 
 
        public CommentHtmlFilter(string commentHtml)
        {
            ParsingError = false;
            foreach (string s in StopList)
                if (commentHtml.IndexOf(s.ToLower(), StringComparison.Ordinal) > -1)
                {
                    ParsingError = true;
                    return;
                }
            _documentOut = new HtmlDocument { OptionFixNestedTags = true, OptionWriteEmptyNodes = true };
            _documentIn = new HtmlDocument { OptionFixNestedTags = true, OptionWriteEmptyNodes = true };
            _documentIn.LoadHtml(commentHtml);
            if (_documentIn.ParseErrors != null && _documentIn.ParseErrors.Any())
                ParsingError = true;
        }
 
 
        public string GetResult()
        {
            if (ParsingError)
                return "";
            Rebuilder(_documentIn.DocumentNode, _documentOut.DocumentNode);
            MemoryStream ms = new MemoryStream();
            _documentOut.Save(ms, Encoding.UTF8);
            ms.Position = 0;
            return new StreamReader(ms).ReadToEnd();
        }
 
 
        private void Rebuilder(HtmlNode node, HtmlNode node2)
        {
            foreach (HtmlNode childNode in node.ChildNodes)
            {
                string tagName = childNode.Name;
                if (tagName.Equals("#text"))
                    node2.AppendChild(childNode);
                if (Array.Exists(TrustTags, i => i.Equals(tagName)))
                {
                    HtmlNode newnode = _documentOut.CreateElement(tagName);
                    foreach (HtmlAttribute htmlAttribute in childNode.Attributes)
                    {
                        string attrName = htmlAttribute.Name;
                        if (Array.Exists(TrustAttr, i => i.Equals(attrName)))
                            newnode.Attributes.Add(attrName, htmlAttribute.Value);
                    }
                    node2.AppendChild(newnode);
                    if (childNode.ChildNodes.Count > 0)
                        Rebuilder(childNode, newnode);
                }
            }
        }
    }
}

 Работа с классом:

CommentHtmlFilter htmlFilter = new CommentHtmlFilter(model.Text);
if (htmlFilter.ParsingError)
    return Json(new Dictionary<string, string> { { "Status", "Error" }, { "Comment",
    "В тексте комментария обнаружены ошибки форматирования, добавление комментария невозможно" } }, JsonRequestBehavior.AllowGet);
string textComment = htmlFilter.GetResult();

Итак, сначала как происходит работа с классом.

  • создается экземпляр класса, в конструктор отправляется исходный сырой html-код, пришедший из формы добавления комментариев
  • если обнаружена ошибка в разметке, то пользователю сразу отдается ошибка отправки
  • иначе идет вызов метода GetResult, который отдает профильтрованную разметку

Теперь вкратце о классе.

При фильтрации используется Html Agility Pack, который можно поставить через Nuget.

Члены класса:

  1. string[] StopList - массив, в котором указываются запрещенные теги
  2. string[] TrustTags - массив, в котором указываются разрешенные теги
  3. string[] TrustAttr - массив, в котором указываются разрешенные свойства тегов
  4. метод GetResult(), который вызывается для получения результата
  5. метод Rebuilder(HtmlNode node, HtmlNode node2), с помощью которого пересобираются ноды html-документа

При создании экземпляра класса вызывается его конструктор. Первое, что он делает, это проверяет входной документ на наличие запрещенных тегов и если такие имеются, то сразу отдает невалид. Если запрещенных тегов не найдено, то далее инициализируются 2 поля HtmlDocument. В первый сразу же загружается полученный на входе конструктора код.

Если экземпляр класса проинициализировался и поле ParsingError равно false, значит можно вызывать метод GetResult(). В нем сразу же вызывается Rebuilder. У последнего 2 входных аргумента, первый - это нода, которую мы сейчас будем разбирать, второй - это нода, в которую будем собирать элементы. Сразу запускаем цикл по дочерним нодам node, в котором и идет определение, что можно копировать в ноду node2, а что пропускаем. Для полного прохода по дереву документа метод Rebuilder для дочерних членов вызывается рекурсивно.

Как видно, данный класс не просто удаляет опасные элементы, он полностью пересобирает входной html-документ, отбирая только те теги, что разрешены. За счет этого также обеспечивается валидность разметки на выходе.

Рад, если кому-то пригодится данный класс. Нашли косяк - пишите, буду благодарен. Удачи.