让用户输入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代码,在客户端实现实时过滤(预览)。事实上,我这个博客的评论系统也是用类似的方式实现的。