Обрезка HTML тегов с фильтрцией по белому списку в C#

HTML/XML

Выполнить обрезку HTML тегов с помощью C# можно многими способами, но каждый имеет свои премущества и недостатки, особенности реализации и дополнительные ньюансы. В этой статье я хочу доступно объяснить те подходы к решению этой задачи, которые помогли мне, и показать какие ньюансы могут возникнуть при их использовании.

Для начала нужно уточнить что же такое обрезка HTML тегов и зачем она нужна. Под обрезкой тегов я понимаю полное или частичное удаление HTML тегов из строки. Говоря про частичное удаление, я подразумеваю оставление тех тегов и их атрибутов, которые находятся в белом списке, тоесть в списке разрешенных тегов. Понадобиться такая операция вам может, например, если при оставлении комментария на вашем сайте вы хотите разрешить пользователю вводить только некоторый ограниченный набор HTML тегов. Или же вы могли спарсить HTML контент из какого то источника, и при вставке его на свой сайт также хотите оставить только некоторые теги (<p>,<br />, <strong>, …).

Для тестов будем использовать следующую строку:

<div><p style='text-align:center;'>Some <font size='3' color='red'>text in font</font> - another <br /> <customAttr>text in custom attribute</customAttr> <br> More Text</p><div>

Элементарная обрезка с помощью регулярных выражений

Если надо полностью обрезать все без исключения HTML теги из строки, то можно воспользоваться одним простым и элегантным решением на основе несложного регулярного выражения:

		static string StripHtmlTagsUsingRegex(string inputString)
        {
            return Regex.Replace(inputString, @"<[^>]*>", String.Empty);
        }

Для рассматриваемой нами входной строки этот метод вернет такой результат:

Some text in font - another  text in custom attribute  More Text

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

		static string StripHtmlTagsUsingRegex(string inputString)
        {            
            return Regex.Replace(inputString, @"<(?!br|p|\/p)[^>]*>", String.Empty);
        }

Тогда получим такой результат, где будут сохранены теги <p> и <br/>:

<p style='text-align:center;'>Some text in font - another <br /> text in custom attribute <br> More Text</p>

Но как видим, оставляя теги, мы оставляем также все их атрибуты. Можно конечно продолжить расширение нашего простого регулярного выражения, но тогда оно будет уже не очень простым.
Если уж вам нужна какая то более изощренная обрезка тегов, то лучше не экспериментировать, а использовать уже готовые оттестированные решения.
Дальше я представлю несколько вариантов, которые я нашел в сети.

Обрезка с помощью Html Agility Pack

Если поискать решение задачи которая является темой этой статьи, то не cложно заметить, что на многих сайтах предлагают использовать Html Agility Pack (HAP). Эта библиотека позволяет в удобной форме работать с DOM деревом HTML документа, и ее использование вполне целесообразно.

Вот интересный пример взятый отсюда:

public static class HtmlUtility
{
    // Original list courtesy of Robert Beal :
    // http://www.robertbeal.com/37/sanitising-html

    private static readonly Dictionary<string, string[]> ValidHtmlTags =
        new Dictionary<string, string[]>
        {
            {"p", new string[]          {"style", "class", "align"}},
            {"div", new string[]        {"style", "class", "align"}},
            {"span", new string[]       {"style", "class"}},
            {"br", new string[]         {"style", "class"}},
            {"hr", new string[]         {"style", "class"}},
            {"label", new string[]      {"style", "class"}},

            {"h1", new string[]         {"style", "class"}},
            {"h2", new string[]         {"style", "class"}},
            {"h3", new string[]         {"style", "class"}},
            {"h4", new string[]         {"style", "class"}},
            {"h5", new string[]         {"style", "class"}},
            {"h6", new string[]         {"style", "class"}},

            {"font", new string[]       {"style", "class", "color", "face", "size"}},
            {"strong", new string[]     {"style", "class"}},
            {"b", new string[]          {"style", "class"}},
            {"em", new string[]         {"style", "class"}},
            {"i", new string[]          {"style", "class"}},
            {"u", new string[]          {"style", "class"}},
            {"strike", new string[]     {"style", "class"}},
            {"ol", new string[]         {"style", "class"}},
            {"ul", new string[]         {"style", "class"}},
            {"li", new string[]         {"style", "class"}},
            {"blockquote", new string[] {"style", "class"}},
            {"code", new string[]       {"style", "class"}},

            {"a", new string[]          {"style", "class", "href", "title"}},
            {"img", new string[]        {"style", "class", "src", "height", "width",
                "alt", "title", "hspace", "vspace", "border"}},

            {"table", new string[]      {"style", "class"}},
            {"thead", new string[]      {"style", "class"}},
            {"tbody", new string[]      {"style", "class"}},
            {"tfoot", new string[]      {"style", "class"}},
            {"th", new string[]         {"style", "class", "scope"}},
            {"tr", new string[]         {"style", "class"}},
            {"td", new string[]         {"style", "class", "colspan"}},

            {"q", new string[]          {"style", "class", "cite"}},
            {"cite", new string[]       {"style", "class"}},
            {"abbr", new string[]       {"style", "class"}},
            {"acronym", new string[]    {"style", "class"}},
            {"del", new string[]        {"style", "class"}},
            {"ins", new string[]        {"style", "class"}}
        };

    /// <summary>
    /// Takes raw HTML input and cleans against a whitelist
    /// </summary>
    /// <param name="source">Html source</param>
    /// <returns>Clean output</returns>
    public static string SanitizeHtml(string source)
    {
        HtmlDocument html = GetHtml(source);
        if (html == null) return String.Empty;

        // All the nodes
        HtmlNode allNodes = html.DocumentNode;

        // Select whitelist tag names
        string[] whitelist = (from kv in ValidHtmlTags
                              select kv.Key).ToArray();

        // Scrub tags not in whitelist
        CleanNodes(allNodes, whitelist);

        // Filter the attributes of the remaining
        foreach (KeyValuePair<string, string[]> tag in ValidHtmlTags)
        {
            IEnumerable<HtmlNode> nodes = (from n in allNodes.DescendantsAndSelf()
                                           where n.Name == tag.Key
                                           select n);

            if (nodes == null) continue;

            foreach (var n in nodes)
            {
                if (!n.HasAttributes) continue;

                // Get all the allowed attributes for this tag
                HtmlAttribute[] attr = n.Attributes.ToArray();
                foreach (HtmlAttribute a in attr)
                {
                    if (!tag.Value.Contains(a.Name))
                    {
                        a.Remove(); // Wasn't in the list
                    }
                    else
                    {
                        // AntiXss
                        a.Value =
                            Microsoft.Security.Application.Encoder.UrlPathEncode(a.Value);
                    }
                }
            }
        }

        return allNodes.InnerHtml;
    }

    /// <summary>
    /// Takes a raw source and removes all HTML tags
    /// </summary>
    /// <param name="source"></param>
    /// <returns></returns>
    public static string StripHtml(string source)
    {
        source = SanitizeHtml(source);

        // No need to continue if we have no clean Html
        if (String.IsNullOrEmpty(source))
            return String.Empty;

        HtmlDocument html = GetHtml(source);
        StringBuilder result = new StringBuilder();

        // For each node, extract only the innerText
        foreach (HtmlNode node in html.DocumentNode.ChildNodes)
            result.Append(node.InnerText);

        return result.ToString();
    }

    /// <summary>
    /// Recursively delete nodes not in the whitelist
    /// </summary>
    private static void CleanNodes(HtmlNode node, string[] whitelist)
    {
        if (node.NodeType == HtmlNodeType.Element)
        {
            if (!whitelist.Contains(node.Name))
            {
                node.ParentNode.RemoveChild(node);
                return; // We're done
            }
        }

        if (node.HasChildNodes)
            CleanChildren(node, whitelist);
    }

    /// <summary>
    /// Apply CleanNodes to each of the child nodes
    /// </summary>
    private static void CleanChildren(HtmlNode parent, string[] whitelist)
    {
        for (int i = parent.ChildNodes.Count - 1; i >= 0; i--)
            CleanNodes(parent.ChildNodes[i], whitelist);
    }

    /// <summary>
    /// Helper function that returns an HTML document from text
    /// </summary>
    private static HtmlDocument GetHtml(string source)
    {
        HtmlDocument html = new HtmlDocument();
        html.OptionFixNestedTags = true;
        html.OptionAutoCloseOnEnd = true;
        html.OptionDefaultStreamEncoding = Encoding.UTF8;

        html.LoadHtml(source);

        return html;
    }
}

Этот статический класс имеет два публичных метода — StripHtml() для удаления всех тегов и SanitizeHtml() для «дезинфицирования» HTML, тоесть удаления всех тегов кроме белого списка. Использовать его можно следующим образом:

        static string StripHtmlTagsUsingHtmlAgilityPack(string inputString)
        {
            return HtmlUtility.SanitizeHtml(inputString);
        }

Не буду вдаваться в подробности реализации самого класса HtmlUtility, а только обращу ваше внимание на интересный нюанс.

Использование метода SanitizeHtml() для нашей тестовой строки даст такой результат:

<div><p style='text-align:center;'>Some <font size='3' color='red'>text in font</font> - another <br>  <br> More Text</p><div></div></div>

Как видите, тег <customAttr> был урезан вместе со всем его содержимым. Хотя мы могли ожидать, что удалится только тег, а текст останется. Более того, если убрать из белого списка тег <div>, то вообще получим пустую строку.
Такое поведение связано с особенностью Html Agility Pack — работа с HTML в нем сводится к работе с нодами, и автор не предусмотрел сохранение внутренних элементов ноды в случае ее отсутствия в белом списке — он просто их удаляет вместе с самой нодой.
В комментариях к приведенной статье кто то предложил решение этой проблемы. Но проведя несколько тестов я пришел к выводу что оно не идеально и в нем явно присутствуют баги.
После неудачных попыток подправить это решение я решил бросить это дело, и нашел еще одну альтернативу, про которую расскажу дальше.

Продвинутая обрезка с помощью регулярных выражений

На помощь пришло решение, представленное тут.

Автор предлагает решение в виде метода расширения, реализованного с помощью регулярных выражений:

/// <summary>
/// Filters HTML to the valid html tags set (with only the attributes specified)
/// 
/// Thanks to http://refactormycode.com/codes/333-sanitize-html for the original
/// </summary>
public static class HtmlSanitizeExtension
{
    private const string HTML_TAG_PATTERN = @"(?'tag_start'</?)(?'tag'\w+)((\s+(?'attr'(?'attr_name'\w+)(\s*=\s*(?:"".*?""|'.*?'|[^'"">\s]+)))?)+\s*|\s*)(?'tag_end'/?>)";

    /// <summary>
    /// A dictionary of allowed tags and their respectived allowed attributes.  If no
    /// attributes are provided, all attributes will be stripped from the allowed tag
    /// </summary>
    public static Dictionary<string, List<string>> ValidHtmlTags = new Dictionary<string, List<string>> {
            { "p", new List<string>() },
			{ "br", new List<string>() },
            { "strong", new List<string>() }, 
            { "ul", new List<string>() }, 
            { "li", new List<string>() }, 
            { "a", new List<string> { "href", "target" } }
    };

    /// <summary>
    /// Extension filters your HTML to the whitelist specified in the ValidHtmlTags dictionary
    /// </summary>
    public static string FilterHtmlToWhitelist(this string text)
    {
        Regex htmlTagExpression = new Regex(HTML_TAG_PATTERN, RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.Compiled);

        return htmlTagExpression.Replace(text, m =>
        {
            if (!ValidHtmlTags.ContainsKey(m.Groups["tag"].Value))
                return String.Empty;

            StringBuilder generatedTag = new StringBuilder(m.Length);

            Group tagStart = m.Groups["tag_start"];
            Group tagEnd = m.Groups["tag_end"];
            Group tag = m.Groups["tag"];
            Group tagAttributes = m.Groups["attr"];

            generatedTag.Append(tagStart.Success ? tagStart.Value : "<");
            generatedTag.Append(tag.Value);

            foreach (Capture attr in tagAttributes.Captures)
            {
                int indexOfEquals = attr.Value.IndexOf('=');

                // don't proceed any futurer if there is no equal sign or just an equal sign
                if (indexOfEquals < 1)
                    continue;

                string attrName = attr.Value.Substring(0, indexOfEquals);

                // check to see if the attribute name is allowed and write attribute if it is
                if (ValidHtmlTags[tag.Value].Contains(attrName))
                {
                    generatedTag.Append(' ');
                    generatedTag.Append(attr.Value);
                }
            }

            generatedTag.Append(tagEnd.Success ? tagEnd.Value : ">");

            return generatedTag.ToString();
        });
    }
}
Обратите внимание что как в классе HtmlUtility так и в HtmlSanitizeExtension есть возможность задать не только белый список разрешенных тегов, но также для каждого из них можно указать разрешенные атрибуты.

Использование этого метода продемонстрированно ниже:

		static string StripHtmlTagsUsingHtmlSanitizeExtension(string inputString)
        {
            return inputString.FilterHtmlToWhitelist();
        }

Получаем такой результат:

<p>Some text in font - another <br/> text in custom attribute <br> More Text</p>

Это именно то чего я добивался — оставить только элементарные теги без аттрибутов, при этом не удаляя никакой текст.

Но ничто не идеально, и я все таки нашел один баг в этом решении. Если в строке будет присутствовать тег с пространством имен, например <customAttr:namespace> то он не будет удален.
Эту проблемку я устранил чуток подправив константу HTML_TAG_PATTERN:

private const string HTML_TAG_PATTERN = @"(?'tag_start'</?)(?'tag'[\w:]+)((\s+(?'attr'(?'attr_name'\w+)(\s*=\s*(?:"".*?""|'.*?'|[^'"">\s]+)))?)+\s*|\s*)(?'tag_end'/?>)";

Если вы обнаружите еще какие то проблемы с приведенными примерами, буду благодарен если напишете о них в комментариях.

Заключение

Как видим подходов к обрезке HTML тегов в C# много. Выбор конкретного способа остается за вами. Если нужно просто удалить все теги, то можно использовать первый способ с регулярным выражением. Если же есть потребность в белом списке разрешенных тегов, то лучше выбрать какое нибуть из готовых решений.

После обрезки тегов, лучше удалить все лишние пробелы и переносы строк. Сделать это можно с помощью такой строки кода:

strippedHtml = Regex.Replace(strippedHtml, @"[\s\r\n]+", " ").Trim();

Исходный код приведенных в этой статье примеров можно скачать по этой ссылке: http://devnuances.com/examples/c_sharp/StripHtmlTags.zip

2 комментариев

  1. CSS 3 на данный момент находится в разработке, и некоторые почтовики считают определённые атрибуты недопустимыми.

  2. Если вы только планируете начать свой бизнес и копите деньги, то уже сегодня необходимо думать о том, чем вы будете заниматься, необходимо просмотреть бизнес-идеи 2018 год и тщательно подготовить свою стратегию развития.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

*

Можно использовать следующие HTML-теги и атрибуты: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>