让用户输入HTML的内容是很常见的需求,但是这有一定危险性,可能会带来XSS等问题,因此一般大家都要对HTML进行一定过滤。这个过滤并不容易,如元素自不必说,其他还有如onload或onclick事件,甚至一个普通的元素,它的href中也可以执行JavaScript代码。以前我一直有一段用于过滤的C#实现,一直没有出篓子,似乎也挺靠谱,但最近不知怎么的却发现了问题,可能是C & P出错,也可能原本就有问题,我没有太去关心。但问题总需要解决,于是我想,不如换个角度,基于白名单进行过滤吧。
以前HTML过滤的方式往往是基于黑名单的,例如去除元素及onload事件等等,万一有所遗漏,便会造成安全上的隐患。而白名单策略则正好相反,我们只列出合法的HTML元素及其属性,如果有所遗漏,则最多导致HTML上无法使用某些元素,但不会有安全问题。具体采用哪种方式,就要看您自己的决策了。在我看来,我并不在意进行白名单过滤,因为从我写博客及做项目的经验上来看,用户根本不需要如此广泛的HTML元素以及完整的属性支持,而且这反而会造成样式上的混乱。甚至说,我们只是需要简单的几种元素,如p、a、h1~h6、strong、ul、ol或是li等等,就足够了。样式问题?由上下文环境来统一控制,这才能得到良好的浏览体验。
配置
首先,我们要准备一份白名单的配置,其中表明哪些HTML元素,以及其中哪些属性,还有属性的哪些形式是合法的。按照传统,配置往往会采用XML格式。不过我觉得XML虽然还算便于表达,但是冗余信息还是太多,且对于“&”等字符还需要转义,因此有时候并不是配置的理想方式。目前常用的配置格式还有JSON,它不像XML那样冗余,也能较好的表现结构化数据,不过由于我们需要表达正则表达式,使用JSON的话在字符串里的转义就麻烦了。至于其他格式,如ini文件,可能并不容易表示带层级的关系 ,或者需要自己写解析方式,于是我最终还是决定使用XML作为配置形式,如下:
^((font|color)\s*:\s*[^;]+;\s*)*$
^[a-zA-Z]+://.+$
.+
...
根元素下的每个tag元素表示一个合法的HTML元素,其中星号表示对所有元素的统一设置,例如上面的配置便开放了所有元素的style属性中的font和color两个设置,以及a元素中的href和title属性,其中href还必须是“{scheme}://xxx”的形式,这样就避免了“javascript:alert(1)”这样的XSS问题。
有了XML格式,则代码自然也是一蹴而就的:
public classTagConfig: Dictionary
{
publicTagConfig()
: base(StringComparer.OrdinalIgnoreCase)
{ }
publicTagConfig(XElementconfig)
{
foreach(varele inconfig.Elements("attr"))
{
this.Add(ele.Attribute("name").Value, newRegex(ele.Value));
}
}
}
public classFilterConfig: Dictionary
{
publicFilterConfig()
: base(StringComparer.OrdinalIgnoreCase)
{ }
publicFilterConfig(XElementconfig)
{
varwildcardElement = config
.Elements("tag")
.SingleOrDefault(e => e.Attribute("name").Value == "*");
varwildcardConfig = wildcardElement == null? null:
newTagConfig(wildcardElement);
foreach(varele inconfig
.Elements("tag")
.Where(e => e.Attribute("name").Value != "*"))
{
varname = ele.Attribute("name").Value;
vartagConfig = newTagConfig(ele);
foreach(varpair inwildcardConfig)
{
if(!tagConfig.ContainsKey(pair.Key))
{
tagConfig.Add(pair.Key, pair.Value);
}
}
this.Add(name, tagConfig);
}
}
}
在操作时,我将星号中的配置加到每个元素中,这是为了简化操作。如果您愿意,自然也可以独立为星号中的配置再过滤一遍。
过滤策略
这里我不打算对HTML进行合法性验证,例如是否匹配等等,我的目的只是保留合法的HTML元素。因此我的策略很简单,使用几个简单的正则表达式就行了。
首先,我使用下面的正则表达式找出所有的HTML元素:
正则:]*>
匹配:123321bbcdde
对于每个HTML元素,我则依次捕获出begin、tag、attr及end四个部分:
正则:^(??)(?[a-zA-z]+)\s*(?[^>]*?)(?/?>)$
最后,从上面得到的attr中,再次捕获到属性的键值对:
正则:(?[a-zA-Z]+)\s*=\s*"(?[^"]*)"
匹配:href="http://blog.zhaojie.me/" title="老赵点滴"
最后,再对捕获到的属性及其值进行过滤即可。简单起见,我在这里只考虑由双引号包含的属性值,因为客户端的富文本编辑器可以保证正规方式提交的HTML格式,至于一些Hacker的做法,我只要保证它不会破坏系统就足够了。总体说来,这样的过滤策略并不严谨,但简单粗暴,还算有效好用。
过滤实现
之前描述的策略,使用C#实现只需短短数十行代码:
public classHtmlFilter{
private static readonlyRegexOptionsREGEX_OPTIONS =
RegexOptions.Compiled |
RegexOptions.IgnoreCase |
RegexOptions.Singleline;
// 依次填入上文中三个正则表达式private static readonlyRegexTAG_REGEX = newRegex(..., REGEX_OPTIONS);
private static readonlyRegexVALID_TAG_REGEX = newRegex(..., REGEX_OPTIONS);
private static readonlyRegexATTRIBUTE_REGEX = newRegex(..., REGEX_OPTIONS);
publicHtmlFilter() : this(null) { }
publicHtmlFilter(FilterConfigconfig)
{
this.Config = config ?? newFilterConfig();
}
publicFilterConfigConfig { get; private set; }
public stringFilter(stringhtml)
{
// 对每个HTML标记进行替换?returnTAG_REGEX.Replace(html, GetTag);
}
private stringGetTag(Matchmatch)
{
// 如果不是合法的HTML标记形式,则替换为空字符串varvalidTagMatch = VALID_TAG_REGEX.Match(match.Value);
if(!validTagMatch.Success) return"";
vartag = validTagMatch.Groups["tag"].Value;
// 如果这个标记不在白名单中,则替换为空字符串TagConfigtagConfig;
if(!this.Config.TryGetValue(tag, outtagConfig)) return"";
varbegin = validTagMatch.Groups["begin"].Value;
// 如果是闭合标记,则直接构造并返回if(begin == "")
{
returnString.Format("{0}>", tag.ToLower());
}
// 过滤出合法的属性键值对varattrText = validTagMatch.Groups["attr"].Value;
varattrMatches = ATTRIBUTE_REGEX.Matches(attrText).Cast();
varvalidAttributes = attrMatches
.Select(m => GetAttribute(m, tagConfig))
.Where(s => !String.IsNullOrEmpty(s)).ToArray();
varend = validTagMatch.Groups["end"].Value;
// 如果没有合法的属性,则直接构造返回if(validAttributes.Length == 0)
{
returnbegin + tag + end;
}
else// 否则返回带属性的HTML标记{
returnString.Format(
"{0}{1} {2}{3}",
begin,
tag,
String.Join(" ", validAttributes),
end);
}
}
private static stringGetAttribute(MatchattrMatch, TagConfigtagConfig)
{
varname = attrMatch.Groups["name"].Value;
Regexregex;
if(!tagConfig.TryGetValue(name, outregex)) return"";
varvalue = attrMatch.Groups["value"].Value;
if(regex.IsMatch(value))
{
returnString.Format("{0}=\"{1}\"", name, value);
}
else{
return"";
}
}
}
您也可以编写一段与之对应的JavaScript代码,在客户端实现实时过滤(预览)。事实上,我这个博客的评论系统也是用类似的方式实现的。