1. 为什么富文本字段的验证这么容易出问题
先问个直白的问题:你在表单里写"验证",脑子里的第一反应是什么?非空、长度、格式。这没错,但一旦字段类型变成"富文本",这三板斧就全都不够用了。
富文本和别人不一样的地方在于,它天生就自带一套HTML。用户在里面写的不是"hello",而是这段:
<p style="color: red;">hello <b>world</b></p>这套HTML是展示层的核心价值——加粗、变色、超链接、列表,全靠它。但问题也出在这里:只要允许用户传入HTML,就等于把一部分代码执行权交了出去。如果只做非空和长度校验,你收到的可能是这种内容:
<script>alert('xss')</script>那这个"验证"就名存实亡了。因为你亲手把一个可执行脚本存进了数据库,再吐回给全站用户。后端那些安全意识不够强的接口,往往就是这么被骗出脏数据的。
所以在正经项目里,富文本字段的验证从来不是一道"是否为空"的判断题,而是一道双向的安检门:
- 内容是否真有业务价值——过滤掉只有标签、没有实质内容的"空富文本";
- 内容是否安全可存储——过滤掉能执行任意脚本的恶意HTML。
这篇不打算贴一个现成的库让你抄完走人,而是完整走一遍我的思考路径:先弄明白服务端验证到底管什么,再摸清清洗脚本的常规实现,接着把那些容易漏掉的边界情况逐个踩一遍,最后聊聊验证不过时该拒绝还是该清洗。这样你自己动手写的时候,才能知道每行代码是干什么的。
2. 先把验证拆成三件事:必填、长度、格式清洗
富文本字段的验证,我习惯在服务端拆成三道关卡。前端也有校验,但那只是给正常用户看的提示牌,不能被当成安全边界。真正不能被绕过的校验,必须发生在数据进入服务器之后。
2.1 必填校验:不是"非空",是"有实际内容"
普通文本字段写个空字符串判断就完事,富文本不行。你想想,用户在编辑器的工具栏上瞎点了一通,插入了一张图片又删掉,最后内容区域里只剩这么点东西:
<p><br></p>字符串长度不为零,但用户其实什么都没写。如果你按"非空即合法"来处理,存进数据库的就是一堆废标签,页面渲染出来是一片空白,不仔细看网页源码你都不知道问题出在哪。
我的判断标准是:先把HTML转成纯文本,去掉所有标签之后,再trim掉空白字符。剩下的内容长度必须大于某个阈值,才算是"有实际内容"。
// 以 Go 为例:把 HTML 转纯文本再判断 func isBlankRichText(htmlContent string) bool { text := stripTags(htmlContent) // 去掉所有标签 if len(strings.TrimSpace(text)) == 0 { return true } return false }2.2 长度校验:按"纯文本"而不是按"标签"算
富文本的长度校验也是个容易翻车的地方。用户加粗几个字、换个字体颜色,标签就一大坨:
<span style="color:#FF0000; font-size: 18px;">一段内容</span>如果按字符串长度统计,这段没多少字的内容轻松就能撑爆你设的长度上限。所以长度校验要分两层:
- 第一层:纯文本长度——用户实际看到了多少个字,这才是有意义的长度;
- 第二层:HTML源码长度——防止有人往HTML标签的属性里塞大量参数,导致整段数据臃肿。
我在项目里通常给纯文本设为一个符合业务值的上限(比如5000字),给原始HTML设置为纯文本上限的2~3倍,专门防这种迂回攻击。
2.3 格式清洗:这是"验证"的核心战场
第三关才是重头戏。所谓格式清洗,不是简单判断"里面有没有script标签",而是把HTML解析出来,逐个节点过一遍:
- 标签名是否在白名单里;
- 属性是否在白名单里;
- 属性的值是否合法(比如href不能以
javascript:开头)。
这一步做扎实了,XSS脚本、危险的协议、非法嵌套基本都能挡住。等到了后文"标签白名单""属性白名单"的环节,我会专门展示我自己的那套过滤列表。
3. 服务端清洗流程与代码示例
工具选型上,其实不用自己从零手写一个HTML解析器。目前主流的做法都是拿着成熟的解析库解析成DOM树,然后遍历节点做"可留则留、该删则删"的判断,最后重新序列化成HTML。我拿Go语言举个例子,用的库是golang.org/x/net/html,Golang官方的扩展包,稳定性比较有保障。
3.1 处理流程总览
我的处理流程分五步走:
| 步骤 | 具体操作 | 目的 |
|---|---|---|
| 1 | 解析HTML字符串为DOM树 | 让每个标签、属性都能被单独审查 |
| 2 | 遍历所有节点,逐个判断标签名 | 不在白名单的标签直接移除内容 |
| 3 | 对保留的标签检查属性 | 只留下安全属性,其他统统删除 |
| 4 | 对特定属性做值校验 | 拦截伪造的协议头 |
| 5 | 重新序列化为HTML字符串 | 输出可存储的干净HTML |
3.2 标签白名单怎么定
白名单的思路很简单:在富文本编辑器里,用户能用到的无非是排版、加粗、斜体、超链接、图片这些基本功能。那白名单就只保留这些标签,其他的一律不放行。
var allowedTags = map[string]bool{ "p": true, "br": true, "b": true, "strong": true, "i": true, "em": true, "u": true, "strike": true, "a": true, "img": true, "ul": true, "ol": true, "li": true, "blockquote": true, "pre": true, "code": true, "h2": true, "h3": true, "h4": true, }注意一个细节:h1标签我默认不放行。原因不是技术上的,而是内容排版上的。富文本内容通常嵌在页面里,页面上已经有自己的H1标题了,如果正文里再混入一个H1,整页的标题层级会乱掉。这种属于"实际业务里走出来的坑",每个团队的约定可以不一样,但一定要有约定。
3.3 属性白名单与值校验
标签过关了,属性也得过一道筛子。比如a标签如果你不管它的href,人家就能夹带私货:
<a href="javascript:alert(1)">点击</a>这就是经典的XSS攻击手法,点击链接直接执行脚本。我的做法是:给每个标签单独声明它允许携带的属性,示例里写成白名单结构,再单独加一层属性值校验。
var allowedAttrs = map[string]map[string]bool{ "a": {"href": true, "title": true}, "img": {"src": true, "alt": true, "width": true, "height": true}, // 其他标签若没声明属性,一律视为无属性合法 } func sanitizeAttrs(n *html.Node) { if n.Type != html.ElementNode { return } tag := n.Data var kept []html.Attribute for _, attr := range n.Attr { // 1. 判断属性是否在该标签允许的范围内 if !allowedAttrs[tag][attr.Key] { continue } // 2. 对URL类型的属性,强制校验协议头 if attr.Key == "href" || attr.Key == "src" { if !isSafeURL(attr.Val) { continue } } kept = append(kept, attr) } n.Attr = kept } func isSafeURL(raw string) bool { raw = strings.TrimSpace(strings.ToLower(raw)) if strings.HasPrefix(raw, "javascript:") { return false } if strings.HasPrefix(raw, "data:text/html") { return false } // 允许 http / https / 相对路径 / 纯锚点 if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "#") || strings.HasPrefix(raw, "mailto:") { return true } return false }你可能会问:直接在属性白名单里不让href出现不就行了?不行,因为超链接是富文本的基本功能,你把它禁了,用户的排版需求就废了一半。所以正确的姿势是"允许它,但盯着它的值"。
3.4 用正则做补充校验
虽然解析器是主力,但正则也不是不能用。我一般拿正则做辅助校验,而不是主过滤逻辑。原因很简单:HTML的嵌套结构太复杂,正则没法可靠地解析DOM树。比如下面这个标签叠加写法,用正则匹配<script>基本配不干净:
<scr<script>ipt>alert(1)</script>真正拿正则干活的场景是数据入库前的最后一道兜底检查:比如全局搜一遍onerror=、onload=这类事件属性名,如果解析器因为某些边界情况漏过了,这层兜底能把最后漏网的干掉。
注意:正则兜底永远有极限,不要试图用正则来解决全部HTML清洗问题。正确定位是"辅助"和"最后一道",主filter必须靠HTML解析器。
4. 必须人工盯防的边界情况
上面那套流程能解决80%的常规问题,但剩下20%是藏在犄角旮旯的边界情况。这些情况不处理,测试环境能过,上了生产就出事故。
4.1 空白字符与零宽字符
用户从Word或者其他编辑器复制内容时,最容易带进来一堆不可见字符:不断行空格(\u00A0)、零宽空格(\u200B)、全角空格。这些字符在页面上看不见,但会让你的纯文本长度校验结果虚高,入库的数据也带着一股"怪味儿"。
清洗方案:在转纯文本时,把所有Unicode空白类字符统一规约成半角空格,再做trim处理。
4.2 图片标签的src属性
我见过不少团队在属性白名单里直接放行img标签的src,理由是"就一个图片地址,能出什么事"。但你要知道,src如果被篡改成data:image/svg+xml;base64,...这种格式,SVG文件里是可以内嵌脚本的。这就是SVG XSS攻击。
所以我在isSafeURL里特意把data:text/html拦掉了,但data:image这类以data:开头的协议也需要单独评估。我的经验是:如果业务上确实允许base64图片,那要求服务端有独立的图片上传接口,禁止把大段base64内容存在富文本字段里。不然数据臃肿只是一方面,隐患才是要命的。
4.3 标签嵌套错乱
还有一种情况:用户手动改HTML源码,或者从别的系统直接搬运,标签没闭合就贴进来了:
<p><b>加粗文字<p>新段落</b></p>这种HTML虽然不标准,但浏览器会自动容错,渲染出来可能就是"加粗段落到第二行还在加粗"。你清洗的时候不能抱着"用户输入不合法就直接报错"的心态,因为前端编辑器可能已经帮你把内容改得七七八八了。
清洗逻辑应该是:解析器解析不出来就丢弃,解析结果为nil就整段拒绝,而不是搞模糊匹配。宁可不存,也不能存进去一段不可预知的HTML。
4.4 事件属性:你漏掉的"合法"属性
再提醒一个容易被忽略的点:你在标签白名单里放行了p标签,但p标签的onmouseover属性呢?
<p onmouseover="alert(document.cookie)">内容</p>解析器会巴不得让你把onmouseover留在属性列表里——如果你没做属性白名单的话,任何标签上的任何事件监听属性都会原样保留。
这类属性在XSS攻击里简直是重灾区:onload、onerror、onclick、onmouseover、onfocus,不管标签名是什么,这些事件属性只要出现,一律不得入内。这也是为什么我强烈建议在属性白名单里使用"允许清单"而不是"禁用清单"。禁用的思路永远是滞后的,因为新的攻击方式层出不穷;而允许的思路是收缩的,除非有业务需要,否则一律不放行。
5. 验证不通过时:拒绝还是清洗?
写服务端验证的时候,很多人会陷入一个纠结:用户提交的内容不合规,我们到底应该直接返回错误让用户重填,还是悄悄把不合规的标签清洗掉再入库?
我的观点是:不同场景用不同策略,但必须在设计阶段就定好,不能两套方案混着用。
5.1 场景A:核心业务数据——建议"拒绝"
比如用户发一篇博客、一条商家简介、一份商品详情。这类内容的核心价值就在HTML本身,你如果自作主张清洗掉了段落的span样式,用户回来看见自己排版全乱了,体验非常糟糕。
这种场景我建议:
- 允许哪些标签、哪些属性,前台编辑器的配置就和后台校验白名单保持一致;
- 不合规内容直接返回
400 Bad Request,提示用户"存在不支持的内容格式"; - 不搞静默清洗,让用户自己决定是不是要去掉某些特殊格式。
5.2 场景B:低价值或高风险数据——建议"清洗"
比如评论留言、站内信摘要、第三方同步来的页面片段。这类内容本身就不需要太丰富的格式,用户甚至未必能感知到样式变化,那就可以直接清洗后存储。目的只有一个:不发生安全事故,比保住排版完整更重要。
反过来说,你对这类低价值字段做了静默清洗,攻击者也没法通过"反馈差异"来探测你到底允许哪些标签,这会抬高攻击成本。
5.3 我对"拒绝"和"清洗"的分工习惯
我个人的项目里,通常设一个总开关:strictMode bool。核心业务数据开true,逐条校验报错;非核心数据开false,全部走清洗流程。这样后期需求变了,只需要在配置中心改一个开关,不用改代码。
有时候也会遇到一种中间情况:内容里90%都是合法的,只有少数几个标签有问题。清洗模式直接把问题标签剥离掉,内容主体还在;拒绝模式则会导致整段数据被退回,用户得自己排查是哪个标签出了问题。这两种取舍没有绝对的对错,要根据数据的业务价值和用户的操作成本来判断。
6. 存储、读取与后续维护中的实战细节
清洗和验证做完,数据入库并不意味着万事大吉。真正上线之后,还有几个细节能直接提升系统的稳定性和运维体验。
6.1 入库时同步一份"纯文本摘要"
我在设计表结构时,会在富文本字段旁边单独加一列plain_text,就是入库前清洗出的纯文本内容。这个字段的作用不是给用户看的,但我强烈建议保留:
- 搜索功能:全文检索不需要处理标签干扰,直接搜纯文本字段就行;
- 列表页摘要展示:列表页展示不需要HTML渲染,取纯文本字段截断就行了;
- 统计数据:统计"这篇文章有多少字",计算的也是纯文本长度。
这个字段是线上跑了一个月后我会强烈建议团队补上的。不是因为一开始没想到,而是很多搜索引擎对带标签的内容处理并不理想,有了纯文本字段之后,很多产品需求比如"相关文章推荐"都能直接在上面跑,开发成本低很多。
6.2 数据库字段类型选择
富文本清洗后的HTML长度不定,中文内容加标签的膨胀率很可观。数据库字段在MySQL里用MEDIUMTEXT起步,最大能装16MB,别再用TEXT(最大64KB)硬撑,不然数据一长,会碰上写入超限的故障。
6.3 定义校验结果的结构体
实际操作中,我不太建议函数返回一个布尔值就完事,因为接口调用方太需要知道"到底哪里出了问题"。我会定义一个结果结构:
type SanitizeResult struct { CleanedHTML string // 清洗后的HTML PlainText string // 纯文本 Modified bool // 是否发生过内容变更 Rejected bool // 是否被拒绝(严重问题) Reason string // 拒绝/变更的原因描述 }这样拿到结果后,接口层可以按状态码分别处理:Rejected=true返回400,Modified=true但Rejected=false走清洗入库流程,同时把原因记到请求日志里方便排查。
7. 实操经验两则
最后分享两个比较偏门的经验,也算是踩坑换来的。
第一个:白名单测试用例要覆盖"删除后重插"的原子性。
有一段时间我在写清洗器的时候,测试用例只顾着"输入恶意标签,断言输出里没有这个标签"。但后来发现有些场景是标签本身被删了,但它的子孙节点还留在原地。比如:
<script>alert(1)<p>内容还在</p></script>清洗的时候只删了<script>,却把<p>内容还在</p>留了下来。这个"内容还在"看着无害,但问题是它的“父节点被删”这个状态,在某些解析器里重新序列化时会导致层级关系错乱。后来我的测试用例就多了一条铁律:凡是被判定为“删除”的节点,连同它的子节点必须一起从DOM树上摘除。简单说就是“要么整棵子树走人,要么全留。”
第二个:清洗规则要跟着编辑器升级而升级。
富文本编辑器每隔一阵子会出新版本,工具栏里多一两个新控件很常见。如果你后台的白名单没跟上,就会发生一种很尴尬的事:编辑器的预览效果是正常的,保存到数据库之后再看,样式全丢了。这种故障的排查链路特别长——前端编辑器、接口、清洗器、存储,每一层都可能是元凶。建议把清洗白名单和编辑器工具栏控件配置放进同一份代码仓库,升级时同步review,避免疏漏。
8. 写在最后
富文本字段的验证是那种"看起来简单,做起来全是细节"的活。光是非空和长度校验,撑不起一个能上生产环境的内容接口。服务端清洗白名单、URL协议校验、边界情况清理、拒绝或清洗的策略划分,这些加起来才是一道完整的安检流程。
我在实战里的体会是:这一套东西最怕的不是攻击者的手段复杂,而是开发团队把"验证"当成一个不能再简单的工具函数来写。只要愿意在这件事上多花一天时间把DOM树级别的清洗流程想清楚,你后面能省下的是数不清的安全工单和数据修复工作。直接套用这篇的流程动手改吧,等你在测试环境用恶意数据跑完一遍,就能明显感觉到数据入口这道关卡到底该长什么样。