Hello World
Spiga

对HTML做白名单过滤

2010-10-19 15:43 by 老赵, 3134 visits

让用户输入HTML的内容是很常见的需求,但是这有一定危险性,可能会带来XSS等问题,因此一般大家都要对HTML进行一定过滤。这个过滤并不容易,如<script />元素自不必说,其他还有如onload或onclick事件,甚至一个普通的<a />元素,它的href中也可以执行JavaScript代码。以前我一直有一段用于过滤的C#实现,一直没有出篓子,似乎也挺靠谱,但最近不知怎么的却发现了问题,可能是C & P出错,也可能原本就有问题,我没有太去关心。但问题总需要解决,于是我想,不如换个角度,基于白名单进行过滤吧。

以前HTML过滤的方式往往是基于黑名单的,例如去除<script />元素及onload事件等等,万一有所遗漏,便会造成安全上的隐患。而白名单策略则正好相反,我们只列出合法的HTML元素及其属性,如果有所遗漏,则最多导致HTML上无法使用某些元素,但不会有安全问题。具体采用哪种方式,就要看您自己的决策了。在我看来,我并不在意进行白名单过滤,因为从我写博客及做项目的经验上来看,用户根本不需要如此广泛的HTML元素以及完整的属性支持,而且这反而会造成样式上的混乱。甚至说,我们只是需要简单的几种元素,如p、a、h1~h6、strong、ul、ol或是li等等,就足够了。样式问题?由上下文环境来统一控制,这才能得到良好的浏览体验

配置

首先,我们要准备一份白名单的配置,其中表明哪些HTML元素,以及其中哪些属性,还有属性的哪些形式是合法的。按照传统,配置往往会采用XML格式。不过我觉得XML虽然还算便于表达,但是冗余信息还是太多,且对于“&”等字符还需要转义,因此有时候并不是配置的理想方式。目前常用的配置格式还有JSON,它不像XML那样冗余,也能较好的表现结构化数据,不过由于我们需要表达正则表达式,使用JSON的话在字符串里的转义就麻烦了。至于其他格式,如ini文件,可能并不容易表示带层级的关系 ,或者需要自己写解析方式,于是我最终还是决定使用XML作为配置形式,如下:

<?xml version="1.0" encoding="utf-8" ?>
<config>
  <tag name="*">
    <attr name="style">^((font|color)\s*:\s*[^;]+;\s*)*$</attr>
  </tag>
  <tag name="a">
    <attr name="href">^[a-zA-Z]+://.+$</attr>
    <attr name="title">.+</attr>
  </tag>
  ...
</config>

根元素下的每个tag元素表示一个合法的HTML元素,其中星号表示对所有元素的统一设置,例如上面的配置便开放了所有元素的style属性中的font和color两个设置,以及a元素中的href和title属性,其中href还必须是“{scheme}://xxx”的形式,这样就避免了“javascript:alert(1)”这样的XSS问题。

有了XML格式,则代码自然也是一蹴而就的:

public class TagConfig : Dictionary<string, Regex>
{
    public TagConfig()
        : base(StringComparer.OrdinalIgnoreCase)
    { }

    public TagConfig(XElement config)
    {
        foreach (var ele in config.Elements("attr"))
        {
            this.Add(ele.Attribute("name").Value, new Regex(ele.Value));
        }
    }
}

public class FilterConfig : Dictionary<string, TagConfig>
{
    public FilterConfig()
        : base(StringComparer.OrdinalIgnoreCase)
    { }

    public FilterConfig(XElement config)
    {
        var wildcardElement = config
            .Elements("tag")
            .SingleOrDefault(e => e.Attribute("name").Value == "*");

        var wildcardConfig = wildcardElement == null ? null :
            new TagConfig(wildcardElement);

        foreach (var ele in config
            .Elements("tag")
            .Where(e => e.Attribute("name").Value != "*"))
        {
            var name = ele.Attribute("name").Value;
            var tagConfig = new TagConfig(ele);

            foreach (var pair in wildcardConfig)
            {
                if (!tagConfig.ContainsKey(pair.Key))
                {
                    tagConfig.Add(pair.Key, pair.Value);
                }
            }

            this.Add(name, tagConfig);
        }
    }
}

在操作时,我将星号中的配置加到每个元素中,这是为了简化操作。如果您愿意,自然也可以独立为星号中的配置再过滤一遍。

过滤策略

这里我不打算对HTML进行合法性验证,例如是否匹配等等,我的目的只是保留合法的HTML元素。因此我的策略很简单,使用几个简单的正则表达式就行了。

首先,我使用下面的正则表达式找出所有的HTML元素:

正则:<[^>]*>
匹配:<a href="http://blog.zhaojie.me/">123321</a>bbc<hello />dde

对于每个HTML元素,我则依次捕获出begin、tag、attr及end四个部分:

正则:^(?<begin></?)(?<tag>[a-zA-z]+)\s*(?<attr>[^>]*?)(?<end>/?>)$
匹配:<a href="http://blog.zhaojie.me">

最后,从上面得到的attr中,再次捕获到属性的键值对:

正则:(?<name>[a-zA-Z]+)\s*=\s*"(?<value>[^"]*)"
匹配:href="http://blog.zhaojie.me/" title="老赵点滴"

最后,再对捕获到的属性及其值进行过滤即可。简单起见,我在这里只考虑由双引号包含的属性值,因为客户端的富文本编辑器可以保证正规方式提交的HTML格式,至于一些Hacker的做法,我只要保证它不会破坏系统就足够了。总体说来,这样的过滤策略并不严谨,但简单粗暴,还算有效好用。

过滤实现

之前描述的策略,使用C#实现只需短短数十行代码:

public class HtmlFilter
{
    private static readonly RegexOptions REGEX_OPTIONS =
        RegexOptions.Compiled | 
        RegexOptions.IgnoreCase | 
        RegexOptions.Singleline;

    // 依次填入上文中三个正则表达式
    private static readonly Regex TAG_REGEX = new Regex(..., REGEX_OPTIONS);
    private static readonly Regex VALID_TAG_REGEX = new Regex(..., REGEX_OPTIONS);
    private static readonly Regex ATTRIBUTE_REGEX = new Regex(..., REGEX_OPTIONS);

    public HtmlFilter() : this(null) { }

    public HtmlFilter(FilterConfig config)
    {
        this.Config = config ?? new FilterConfig();
    }

    public FilterConfig Config { get; private set; }

    public string Filter(string html)
    {
        // 对每个HTML标记进行替换?
        return TAG_REGEX.Replace(html, GetTag);
    }

    private string GetTag(Match match)
    {
        // 如果不是合法的HTML标记形式,则替换为空字符串
        var validTagMatch = VALID_TAG_REGEX.Match(match.Value);
        if (!validTagMatch.Success) return "";

        var tag = validTagMatch.Groups["tag"].Value;

        // 如果这个标记不在白名单中,则替换为空字符串
        TagConfig tagConfig;
        if (!this.Config.TryGetValue(tag, out tagConfig)) return "";

        var begin = validTagMatch.Groups["begin"].Value;
        // 如果是闭合标记,则直接构造并返回
        if (begin == "</")
        {
            return String.Format("</{0}>", tag.ToLower());
        }

        // 过滤出合法的属性键值对
        var attrText = validTagMatch.Groups["attr"].Value;
        var attrMatches = ATTRIBUTE_REGEX.Matches(attrText).Cast<Match>();
        var validAttributes = attrMatches
            .Select(m => GetAttribute(m, tagConfig))
            .Where(s => !String.IsNullOrEmpty(s)).ToArray();

        var end = validTagMatch.Groups["end"].Value;
        // 如果没有合法的属性,则直接构造返回
        if (validAttributes.Length == 0)
        {
            return begin + tag + end;
        }
        else // 否则返回带属性的HTML标记
        { 
            return String.Format(
                "{0}{1} {2}{3}",
                begin,
                tag,
                String.Join(" ", validAttributes),
                end);
        }
    }

    private static string GetAttribute(Match attrMatch, TagConfig tagConfig)
    {
        var name = attrMatch.Groups["name"].Value;

        Regex regex;
        if (!tagConfig.TryGetValue(name, out regex)) return "";
        
        var value = attrMatch.Groups["value"].Value;
        if (regex.IsMatch(value))
        {
            return String.Format("{0}=\"{1}\"", name, value);
        }
        else
        {
            return "";
        }
    }
}

您也可以编写一段与之对应的JavaScript代码,在客户端实现实时过滤(预览)。事实上,我这个博客的评论系统也是用类似的方式实现的。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

28 条回复

  1. Arthur1989
    219.133.62.*
    链接

    Arthur1989 2010-10-19 17:46:25

    这个正则式 <[^>]*> 可能会遇到这种情况 <..<..>..> ,不清楚C#中的正则库是不是支持匹配优先的.

  2. 老赵
    admin
    链接

    老赵 2010-10-19 20:07:59

    @Arthur1989

    支持不支持就看怎么写了啊,呵呵。我这个的确很粗糙,不能解决不合法的问题,只是用来解决XSS……

  3. rhapsodyn
    118.114.41.*
    链接

    rhapsodyn 2010-10-19 23:09:20

    它不像XML那样荣誉

    冗余

  4. luotong
    115.50.17.*
    链接

    luotong 2010-10-20 13:55:31

    很希望你写一些.net4并行库的文章。

  5. rudy
    61.152.254.*
    链接

    rudy 2010-10-20 14:03:34

    支持下!!!

  6. 程劭非
    61.172.247.*
    链接

    程劭非 2010-10-20 16:27:50

    用Schema或者XSLT吧,最保险了

  7. 老赵
    admin
    链接

    老赵 2010-10-20 18:05:58

    @程劭非

    没听懂啊,你是指什么啊?

  8. 链接

    zhiyuanfirst 2010-10-23 17:27:03

    不错,受教了。

  9. 链接

    马士华 2010-10-24 11:34:06

    我想劭非的意思是把input转成xml,然后用XML Schema或者XSLT转换成合法的输入。

  10. 老赵
    admin
    链接

    老赵 2010-10-24 13:02:56

    @马士华

    噢,不过不是所有的HTML都是XML,这个解析的步骤不容易啊。

  11. 程劭非
    218.82.141.*
    链接

    程劭非 2010-10-24 23:11:03

    @老赵

    嗯 不是XHTML的就不行了 反正我是觉得正则容易误杀

  12. 程劭非
    61.172.247.*
    链接

    程劭非 2010-10-25 10:50:31

    <a><!--有些很蛋疼的问题,你会很郁闷</a>--></a>
    

    对于HTML这种复杂性 正则有点不够用

  13. recole
    61.144.61.*
    链接

    recole 2010-10-26 14:30:14

    刚开始看正则的时候感觉很头痛。PS:从博客圆进来,除了发现域名和编辑器改之后,其他的竟然基本都是cnblogs的风格,神变形不见,绝!

  14. 老赵
    admin
    链接

    老赵 2010-10-26 15:49:41

    @recole

    博客园那套皮肤本来就是我提供的哇,哈哈。

  15. gl
    123.138.30.*
    链接

    gl 2010-10-26 22:16:36

    学习了,在这问一个题外话:在asp.net mvc 中怎么像asp.net 那样保存页面提交前的状态,在开发中经常来个搜索什么的( 而且下拉框特别多。)谢了。

  16. 老赵
    admin
    链接

    老赵 2010-10-27 00:36:25

    @gl

    用最裸的方法自己维护状态咯。知道WebForm的ViewState多方便了吧。

  17. waynebaby
    112.65.23.*
    链接

    waynebaby 2010-10-29 22:25:34

    白名单html,与简单ubb谁比较好用呢? 我个人倾向ubb ;)

  18. 老赵
    admin
    链接

    老赵 2010-10-30 17:32:19

    @waynebaby

    如果是我选,我也选有限制的markup,比如这里的评论……不过有些时候还是富文本编辑器来的方便呐。

  19. test
    116.231.125.*
    链接

    test 2010-11-03 12:04:38

    临床智库

    Your title here... ==================--------### Your title here...

  20. abcd
    113.140.7.*
    链接

    abcd 2010-11-08 11:34:18

    牛屄滴很啊啊

  21. edie
    120.199.4.*
    链接

    edie 2010-11-29 19:33:03

    如果有一个大量的 非法字符库,需要过滤,比如,骂人的话等。

    大约有几千个,

    浏览者发表了一个内容,

    对他这个内容应该一个一个的替换好呢,还是统一写一个正则:a|b|c|d.... 替换好了

    哪个更快点?

  22. edie
    120.199.4.*
    链接

    edie 2010-11-29 19:35:00

    正则的性能让我有点失望,

    即便是一个字符串提取,任何手工写的代码都比正则快很多。

    但这里一个一个替换会不会更慢?

  23. 老赵
    admin
    链接

    老赵 2010-11-29 19:38:19

    @edie

    性能高低是相对的,够用就好,我用正则时基本不考虑它的性能问题。因为我认为只要正则没有写错,性能总是足够的,有太多其他方面的性能问题等着我去优化。

  24. edie
    120.199.4.*
    链接

    edie 2010-11-29 19:57:12

    我更倾向于:内部代码复杂,性能越优,错误越少,而使用越简单,扩展性越强,

    即使一个数据库访问,内部也要重新封装一遍,能让用3行就可以做到一个数据访问,针对接口编写,可以轻松切换数据库。

    在封装类的时候,我其实还是建议不要用正则,在写工具的时候,是发挥的时候。

    一个例子:

    • xml/A1/A2.xml => 提取 为 A1
    • xml/B1/B2.xml => 提取 为 B1
    • xml/XX.xml => 提取 为 main

    代码:

    string rv = str.regexReplace("^xml/(.*)/.*$", p, "$1");
    if (rv == p)
        rv = "main";
    return rv;
    

    4行代码非常漂亮,可Profiler不同意了。

    改为:

    p = p.Replace("xml/", "");
    int index = p.IndexOf("/");
    if (index == -1)
    {
        return "main";
    }
    else
    {
        return p.Substring(0, index);
    }
    p.Replace("xml/", "")
    

    对垃圾回收造成负担

    string[] ps = p.Split('/');
    if (ps.Length == 3)
    {
        return ps[1];
    }
    else
    {
        return "main";
    }
    p.Split('/')
    

    同理垃圾回收的问题

    最后的代码是

    for (int i = 5, len = p.Length; i < len; i++)
    {
        if (p[i] == '/')
        {
            return p.Substring(4, i - 4);
        }
        else if (p[i] == '.')
        {
            return "main";
        }
    }
    return "main";
    

    他是最快的。

    但是正则 连第2个 第3个都不如。

  25. 老赵
    admin
    链接

    老赵 2010-11-29 23:09:56

    @edie

    我觉得你想太多了,我写代码的时候,一般都是怎么方便怎么来,怎么漂亮怎么来。只要不犯明显的错误,有性能问题的时候再去优化多好。

  26. jquery学习
    114.97.93.*
    链接

    jquery学习 2011-01-30 23:43:44

    评论的这个编辑器是markItUp呢~我用过很爽~~呵呵~~ 正则不太会~~很菜~XML更是让我头疼~~ 唉~~看来要成为高手~~正则和XML还是需要下番功夫的呀~

    额~写上面那么多内容还要让我多写呀~~博主真是牛拜~~ 好了,我已经很给力的在写了~~不要在拒绝我了~~ 额~还是提示让我多写点~~不是在忽悠我吧~ 不可能呀~~别人也没有多少内容就给过了呀~5555555 我就写呀写~我就写呀写~我就写呀写~我就写呀写~我就写呀写~我就写呀写~我就写呀写~我就写呀写~我就写呀写~ 我就写呀写~ 我就写呀写~我就写呀写~ 我就写呀写~ 我就写呀写~我就写呀写~我就写呀写~ 我就写呀写~ 我就写呀写~ 我就写呀写~ 我就写呀写~ 我就写呀写~ 博主~~你的评论功能是不是出问题了呀~~汗呀~~不要这样折磨我呀~~

  27. jquery学习
    114.97.93.*
    链接

    jquery学习 2011-01-30 23:45:09

    额~~汗呀~~博主的编辑器有问题~~ 当第一次到这个页面~~不刷新的话~~ 编辑器下方对应的要显示出来的内容不会显示~~ 汗呀~~怪不得一直提示我,让我多写点呢~原来是一开始没有同步~~哈哈~~ 哥们你要检查检查你的编辑器的代码了呀~嘿嘿~~

  28. g
    218.240.18.*
    链接

    g 2011-03-08 20:40:56

    正则:<[^>]*> 匹配:123321bbcdde 并不匹配123321的。 中间夹了>

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我