1. 为什么富文本字段的验证这么容易出问题先问个直白的问题你在表单里写验证脑子里的第一反应是什么非空、长度、格式。这没错但一旦字段类型变成富文本这三板斧就全都不够用了。富文本和别人不一样的地方在于它天生就自带一套HTML。用户在里面写的不是hello而是这段p stylecolor: red;hello bworld/b/p这套HTML是展示层的核心价值——加粗、变色、超链接、列表全靠它。但问题也出在这里只要允许用户传入HTML就等于把一部分代码执行权交了出去。如果只做非空和长度校验你收到的可能是这种内容scriptalert(xss)/script那这个验证就名存实亡了。因为你亲手把一个可执行脚本存进了数据库再吐回给全站用户。后端那些安全意识不够强的接口往往就是这么被骗出脏数据的。所以在正经项目里富文本字段的验证从来不是一道是否为空的判断题而是一道双向的安检门内容是否真有业务价值——过滤掉只有标签、没有实质内容的空富文本内容是否安全可存储——过滤掉能执行任意脚本的恶意HTML。这篇不打算贴一个现成的库让你抄完走人而是完整走一遍我的思考路径先弄明白服务端验证到底管什么再摸清清洗脚本的常规实现接着把那些容易漏掉的边界情况逐个踩一遍最后聊聊验证不过时该拒绝还是该清洗。这样你自己动手写的时候才能知道每行代码是干什么的。2. 先把验证拆成三件事必填、长度、格式清洗富文本字段的验证我习惯在服务端拆成三道关卡。前端也有校验但那只是给正常用户看的提示牌不能被当成安全边界。真正不能被绕过的校验必须发生在数据进入服务器之后。2.1 必填校验不是非空是有实际内容普通文本字段写个空字符串判断就完事富文本不行。你想想用户在编辑器的工具栏上瞎点了一通插入了一张图片又删掉最后内容区域里只剩这么点东西pbr/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 stylecolor:#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/htmlGolang官方的扩展包稳定性比较有保障。3.1 处理流程总览我的处理流程分五步走步骤具体操作目的1解析HTML字符串为DOM树让每个标签、属性都能被单独审查2遍历所有节点逐个判断标签名不在白名单的标签直接移除内容3对保留的标签检查属性只留下安全属性其他统统删除4对特定属性做值校验拦截伪造的协议头5重新序列化为HTML字符串输出可存储的干净HTML3.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 hrefjavascript: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基本配不干净scrscriptiptalert(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/svgxml;base64,...这种格式SVG文件里是可以内嵌脚本的。这就是SVG XSS攻击。所以我在isSafeURL里特意把data:text/html拦掉了但data:image这类以data:开头的协议也需要单独评估。我的经验是如果业务上确实允许base64图片那要求服务端有独立的图片上传接口禁止把大段base64内容存在富文本字段里。不然数据臃肿只是一方面隐患才是要命的。4.3 标签嵌套错乱还有一种情况用户手动改HTML源码或者从别的系统直接搬运标签没闭合就贴进来了pb加粗文字p新段落/b/p这种HTML虽然不标准但浏览器会自动容错渲染出来可能就是加粗段落到第二行还在加粗。你清洗的时候不能抱着用户输入不合法就直接报错的心态因为前端编辑器可能已经帮你把内容改得七七八八了。清洗逻辑应该是解析器解析不出来就丢弃解析结果为nil就整段拒绝而不是搞模糊匹配。宁可不存也不能存进去一段不可预知的HTML。4.4 事件属性你漏掉的合法属性再提醒一个容易被忽略的点你在标签白名单里放行了p标签但p标签的onmouseover属性呢p onmouseoveralert(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 // 拒绝/变更的原因描述 }这样拿到结果后接口层可以按状态码分别处理Rejectedtrue返回400Modifiedtrue但Rejectedfalse走清洗入库流程同时把原因记到请求日志里方便排查。7. 实操经验两则最后分享两个比较偏门的经验也算是踩坑换来的。第一个白名单测试用例要覆盖删除后重插的原子性。有一段时间我在写清洗器的时候测试用例只顾着输入恶意标签断言输出里没有这个标签。但后来发现有些场景是标签本身被删了但它的子孙节点还留在原地。比如scriptalert(1)p内容还在/p/script清洗的时候只删了script却把p内容还在/p留了下来。这个内容还在看着无害但问题是它的“父节点被删”这个状态在某些解析器里重新序列化时会导致层级关系错乱。后来我的测试用例就多了一条铁律凡是被判定为“删除”的节点连同它的子节点必须一起从DOM树上摘除。简单说就是“要么整棵子树走人要么全留。”第二个清洗规则要跟着编辑器升级而升级。富文本编辑器每隔一阵子会出新版本工具栏里多一两个新控件很常见。如果你后台的白名单没跟上就会发生一种很尴尬的事编辑器的预览效果是正常的保存到数据库之后再看样式全丢了。这种故障的排查链路特别长——前端编辑器、接口、清洗器、存储每一层都可能是元凶。建议把清洗白名单和编辑器工具栏控件配置放进同一份代码仓库升级时同步review避免疏漏。8. 写在最后富文本字段的验证是那种看起来简单做起来全是细节的活。光是非空和长度校验撑不起一个能上生产环境的内容接口。服务端清洗白名单、URL协议校验、边界情况清理、拒绝或清洗的策略划分这些加起来才是一道完整的安检流程。我在实战里的体会是这一套东西最怕的不是攻击者的手段复杂而是开发团队把验证当成一个不能再简单的工具函数来写。只要愿意在这件事上多花一天时间把DOM树级别的清洗流程想清楚你后面能省下的是数不清的安全工单和数据修复工作。直接套用这篇的流程动手改吧等你在测试环境用恶意数据跑完一遍就能明显感觉到数据入口这道关卡到底该长什么样。
