ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

富文本字段验证实战:服务端白名单清洗与XSS防御

富文本字段验证实战:服务端白名单清洗与XSS防御 1. 富文本字段的真实复杂度为什么它不能当普通输入来处理富文本字段的验证大概是内容类系统里最容易被低估的一环。如果你接手的项目里有一个基于 contenteditable 或 iframe 的富文本编辑器那么后端收到的就不是一段普通字符串而是一整段带标签、带属性、带嵌套结构的 HTML。很多团队一开始都把它当 varchar 处理长度限制给一下、转义给一下上线之后也没出什么事直到某一天运营同学往文章里粘贴了一段第三方来源的内容全站弹窗广告就出来了。我接手过不少类似的项目今天就把验证富文本字段这件事从头到尾掰开讲清楚。它解决的不仅仅是这段内容合不合法而是这段内容在展示给其他用户时会不会执行恶意脚本——也就是我们常说的存储型 XSS。适合的人群也很明确写后端接口的、做内容平台的、维护 CMS 的以及所有被运营同学的粘贴操作坑过的人。1.1 富文本验证和普通字段验证的区别在哪普通字段的验证逻辑往往是不允许。比如用户名不允许包含特殊字符手机号必须匹配某段正则年龄必须落在某个区间。但富文本字段的验证逻辑恰恰相反它必须允许绝大多数东西——允许 h1/h2/p 这类结构标签允许 a 标签带 href允许 img 标签带 src允许各种样式。真正要拦的是藏在这些合法标签里的恶意部分。这就带来了一个本质困境你没法用禁止某类字符的思路去做。如果把和都转义掉富文本就变成了一堆纯文本代码段落、加粗、链接全部失效表单验证倒是通过了但用户编辑的内容全部白费。如果把script简单替换成空字符串攻击者换一种编码方式、或者在 SVG 里再嵌套一层就又能绕过去。富文本字段的验证本质上是一个在保留 HTML 语义的前提下剔除可执行能力的结构化解析问题。它要求你先把 HTML 当成一棵语法树去解构再从头构建一棵只包含白名单节点的新树。这跟正则表达式不是一个量级的任务。1.2 我见过最典型的三种伪验证先说说那些看起来有道理、实则在裸奔的方案。第一种是标签黑名单——维护一个禁用标签列表遇到script、iframe、object就删掉。黑名单最大的问题是永远追不上攻击者的想象力。今天你禁了svg明天他就能用math或video触载事件今天你禁了 onerror他还能用 onmouseover、onfocus 等几十个事件属性。第二种是只转义尖括号。把替换成lt;确实能让脚本不再执行但富文本语义也同时被杀死。就算你只对和做转义攻击者发来的 payload 如果已经做了 HTML 实体编码比如#106;avascript:服务端转义逻辑反而会帮他二次解码出可执行代码。这就是经典的二重编码绕过。第三种是前端验证就是全部。很多项目在富文本编辑器层面做了配置禁止插入脚本于是后端就放心地直接入库。但前端的限制只是用户体验层面的引导攻击者完全可以用 curl 直接 POST 一段手工构造的 HTML根本不走编辑器。服务端永远要假设来自网络请求的每一段内容都可能是精心构造的攻击样本。1.3 富文本数据从入库到展示的完整路径理解富文本字段要把它放在完整链路里看。用户在前端编辑器里输入内容编辑器生成 HTML 字符串这个字符串通过接口提交到后端后端如果不做任何处理直接存入数据库然后另一个用户访问文章详情页后端从数据库取出内容原样拼进 HTML 模板返回浏览器浏览器解析这段内容遇到可执行节点就执行。注意最后一步数据库本身不执行任何代码浏览器才执行。所以验证富文本的核心目标是让存入数据库的内容无论如何拼接、无论如何嵌套在浏览器里都只剩展示能力没有执行能力。这也决定了验证动作必须发生在存储之前——也就是服务端接收到请求的那一刻。文章后面我会重点写服务端白名单清洗这是整个防线里最关键的一道闸门也是热搜里提到的对富文本字段的服务端白名单清洗这个思路的具体落地。2. 防御层次设计不把宝全押在清洗器上做安全的人常讲纵深防御富文本验证尤其适用。你不能指望一个清洗函数把所有攻击都挡住正确的做法是把防线拆成三层服务端清洗负责入模这一步CSP 策略负责执行这一步输出转义和内容安全策略负责展示这一步。三层各管一段即使某一层被绕过后续层还能兜底。2.1 第一层服务端白名单清洗唯一真正靠谱的入口校验白名单清洗的逻辑跟黑名单完全相反它不试图识别哪些标签是危险的而是定义一个哪些标签、属性、协议是允许的列表除此之外一律删除或转义。这个思路在防 XSS 领域有非常成熟的实践基础OWASP 的 XSS Prevention Cheat Sheet 里也明确推荐这种基于白名单的富文本清洗策略。具体到实现层面白名单要管住四件事标签白名单h1、h2、p、a、ul、ol、li、img、strong、em、blockquote、pre、code 等等、属性白名单href、src、alt、title、class、id 等、值校验规则href 的协议必须是 http/https/mailto不能以 javascript: 开头、以及嵌套规则的合法性比如 a 标签内不能再嵌套 a 标签。值得强调的一点是清洗动作必须发生在服务端。任何前端编辑器里的 sanitize 配置都只是为了让用户少看到报错不是安全边界。真正可信的边界只有一个后端接口收到数据的那一刻。2.2 第二层给浏览器戴紧箍咒——CSP 策略即使清洗环节出现疏漏CSPContent-Security-Policy仍然能把伤害控制在可接受范围。它的思路是告诉浏览器这个页面只允许从哪些来源加载脚本、样式、图片。比如设置script-src self就意味着除了本站自己的脚本文件任何内联脚本、第三方域名脚本全部拒绝执行——这正是热搜里提到的script-src self的意义。我见过不少团队把 CSP 当成安全洁癖觉得配置麻烦、怕影响业务。实际上对富文本类页面来说CSP 是性价比极高的一道闸门。你不需要把所有指令配到极致只需要先管住脚本来源就有很大收益。一个典型的配置大概是Content-Security-Policy: default-src self; script-src self; style-src self unsafe-inline; img-src * data:; object-src none; base-uri self这一段的意思是默认只信任本站资源脚本只允许加载本站文件不允许内联脚本样式允许本站和内联样式富文本需要内联 style 属性所以这个要放开否则编辑器排版会乱图片可以加载任何域名的也允许 data 协议的图片object、embed 这类插件对象一律不允许。有同学会问既然富文本要保留用户的 style 属性那style-src unsafe-inline不是把 CSS 注入的路子也放开了吗确实会有一定风险所以接下来在清洗环节里style 值本身也要做约束——这就是纵深防御的意思CSP 放开的部分由别的层补上。2.3 第三层输出侧的转义与渲染隔离第三层很多人会忽略就是展示富文本时的输出策略。后端渲染模板时如果直接把数据库里的 html 字段拼到页面上那就把安全完全押在了存储时的清洗质量上。合理的做法是区分两种上下文属性上下文和 HTML 内容上下文。内容型的富文本在模板块里正常插入即可前提是入模时已经做过白名单清洗但如果富文本内容需要出现在某个 HTML 属性的值里比如input value{{ item.content }}就必须做属性转义否则闭合引号就能把你的内容属性给突破掉。另一个思路是隔离渲染比如把用户生成的富文本渲染在独立的 iframe 中sandbox 属性就算内容里有漏网之鱼也逃不出 iframe 的沙箱边界。再补充一个容易被忽视的边界富文本里如果允许img src那么 src 指向的地址是否存在 SSRF 风险如果允许iframe嵌入视频那嵌入的域名是否有策略限制这些都不属于会不会执行脚本的问题但都归属于富文本字段验证这个大的安全主题。3. 服务端白名单验证的技术选型与代码实现聊完了防御层次接下来进入实操环节。我直接告诉你结论不要手写正则来清洗 HTML不要用正则做白名单判断一定要用成熟的 HTML 解析器配合白名单策略来构建新树。原因很简单HTML 的语法容错性极强浏览器对畸形标签、自动闭合、隐式嵌套的处理规则多到令人发指正则根本模拟不了这种解析能力。你自己写的解析规则一定比浏览器更严格或更宽松而这两者都会出问题。3.1 主流语言里怎么选清洗库先看一张选型对照表是我自己在项目里的横向对比结果语言/生态推荐库核心理念注意事项Pythonbleach基于 html5lib 解析严格按 WHATWG 规范走依赖 lxml安装体积稍大JavajsoupSafelist 机制自带 HTML 解析器和 Safelist 白名单 API旧版 Whitelist 已废弃用新 APINode.jssanitize-html配置灵活插件丰富用起来快注意选择有维护时间的版本PHPHTMLPurifier老牌配置细致输出干净性能在大型内容下需要开启缓存我在主力项目中用的是 Python 的 bleach它底层用的是 html5lib这个库会严格按照浏览器解析 HTML 的规范来理解输入所以攻击者想通过畸形标签造成解析歧义的空间就被压缩了。下面我以 bleach 为例展开讲。3.2 Python bleach 的清洗实现详解假设我们要允许一套典型的内容排版标签包括标题、段落、列表、链接、图片、粗斜体等。第一步是定义白名单import bleach ALLOWED_TAGS [ h1, h2, h3, h4, p, br, hr, ul, ol, li, a, img, strong, b, em, i, u, blockquote, pre, code, span, div, table, thead, tbody, tr, th, td, ] ALLOWED_ATTRIBUTES { a: [href, title, target, rel], img: [src, alt, title, width, height], span: [class, style], div: [class, style], p: [class, style], td: [colspan, rowspan, style], th: [colspan, rowspan, style], table: [class, style], code: [class], pre: [class], } ALLOWED_PROTOCOLS [http, https, mailto, tel]这里注意几个细节。第一我推荐加上target_blank相关属性但要强制关联relnoopener noreferrer否则会有 tabnabbing 反向钓鱼风险。第二width/height 如果允许清洗器不会去校验数值是否超限所以建议在业务层再限制一个最大值。第三class 属性建议配合一个类名白名单来用否则用户可以给任何元素挂任意 class 名虽然这不直接产生 XSS但会给前端样式注入带来混乱。接下来执行清洗def sanitize_rich_text(raw_html: str) - str: if not raw_html: return cleaned bleach.clean( raw_html, tagsALLOWED_TAGS, attributesALLOWED_ATTRIBUTES, protocolsALLOWED_PROTOCOLS, stripTrue, # 不允许的标签连同内容一起剥离而不是转义显示 strip_commentsTrue, # 删除所有 HTML 注释 ) # 二次清洗控制长度防止超大内容拖垮存储和渲染 if len(cleaned) 20000: cleaned cleaned[:20000] return cleaned.strip()stripTrue这个参数值得说说。它表示遇到白名单之外的标签时把整个标签删除但保留其中的文本内容。比如用户发来scriptalert(1)/script清洗后得到的就是alert(1)——脚本标签没了但里面的文字还在用户看到的是alert(1) 这句话。这种处理比直接把整块删除体验好得多。而strip_commentsTrue会删除 HTML 注释攻击者有时候会把 payload 藏在注释里注释本身不执行代码但可能被其他逻辑利用直接删掉省心。3.3 针对样式属性、链接协议和内部斜杠的二次校验bleach 的attributes白名单只校验了属性名对于属性值本身只处理了引号转义。所以 style 属性里的内容仍然可能藏雷最常见的是 CSS 表达式注入和background: url(javascript:...)这类玩法。我通常还会加一步针对 style 内容的额外清洗import re CSS_URL_PATTERN re.compile(rurl\s*\(\s*[\]?\s*(.*?)[\]?\s*\), re.IGNORECASE) def sanitize_style_value(style_value: str) - str: # 不允许有任何 url() 形式的资源引用 if CSS_URL_PATTERN.search(style_value): return # 不允许 expression、import、behavior 等危险 CSS 关键字 lowered style_value.lower() for banned in [expression, import, behavior, -moz-binding, javascript:]: if banned in lowered: return # 单条 style 属性值长度控制防止无限堆叠 if len(style_value) 500: return return style_value处理完 style还要对href和src做协议校验。bleach 的protocols参数确实帮忙挡了javascript:但钓鱼场景里还有一种常见手法是反斜杠混淆hrefjava\script:...部分浏览器在解析时会把反斜杠当正斜杠处理。保险的做法是自己在清洗后再校验一次def validate_href(href: str) - str: # 剥掉首尾空白和引号 href href.strip() # 统一转小写再做协议检查 lowered href.lower() if lowered.startswith((javascript:, vbscript:, data:, file:)): return # 校验没有反斜杠混淆 if \\ in href: return return href为什么要做这一层二次校验因为安全库的更新周期往往慢于攻击手法的迭代速度而你自己写的那三五条规则可以在发现问题时立刻热修。把清洗做成库层白名单 业务层硬规则两道工序是我实践下来可靠性最高的组合。3.4 Java/jsoup 侧的实现对照如果你的技术栈是 Javajsoup 的SafelistAPI 提供了几乎是同构的能力。新版 API 长这样import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.safety.Safelist; Safelist safelist Safelist.relaxed() .addTags(h1, h2, h3, p, br, pre, code) .addAttributes(a, href, title, rel) .addAttributes(img, src, alt, width, height) .addProtocols(a, href, http, https, mailto) .addProtocols(img, src, http, https); String dirty scriptalert(1)/scriptpHello a hrefjavascript:alert(1)world/a/p; String cleaned Jsoup.clean(dirty, http://your-domain.example, safelist);jsoup 的clean方法接受一个 Base URI 参数这个参数也挺关键——当富文本里的链接是相对路径时它会解析成绝对地址避免前端拿到一堆中文乱码路径。4. 攻击者视角清洗器要面对的绕过手法与误杀问题验证富文本字段本质上是和攻击者玩一场猫鼠游戏。我给自己定了一个习惯每写一个新的清洗规则就先试着绕过它。以下这些是我实际收集到的高频攻击向量也是测试用例的核心来源。4.1 事件属性与协议注入最常见也最容易一脚踩空的深坑事件属性是 XSS 的第一大类入口。onerror、onload、onmouseover、onfocus、onclick这些都是可以在合法标签上的附加属性比如img src1.png onerroralert(1)图片加载失败时脚本立刻执行。如果你的属性白名单里忘了限制 img 标签只能有 src/alt 这些属性那攻击者随便加一个 onerror 就绕过了。这也是我上面定义ALLOWED_ATTRIBUTES时反复强调属性也要白名单的原因。协议注入是第二大入口。href、src、action、formaction这些属性可以被赋值为不同协议。javascript:alert(1)是最基础的进阶版是编码混淆a hrefjava#x73;cript:alert(1)click/a a hrefjavas%63ript:alert(1)click/a a hrefdata:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgclick/a这里有个关键认知清洗器解析 HTML 时浏览器也会解析 HTML但两者对字符引用的解码顺序不完全一致。攻击者正是瞄准这些不一致来构造清洗时看起来安全、浏览器执行时却是恶意的 payload。所以协议校验必须在标签解析之后、属性值解码完成之后再做一次这就是我前面提二次校验的原因。4.2 SVG、iframe、MathML 和 CSS 注入的进阶级玩法SVG 是一类很特别的矢量图格式它在 HTML 文档里可以被直接当作嵌入对象渲染而且 SVG 内部支持script标签和各种事件属性。即使你过滤了script一个svgscript如果被浏览器当 SVG 文档解析依然可能执行。所以安全类的白名单通常直接禁用svg、math、iframe、object、embed这类标签。业务上如果确实需要嵌入视频运营人员通常走专门的视频组件路由而不是让用户在富文本里自由贴 iframe。CSS 注入也不可小觑。除了前面提到的url(javascript:)还有一种玩法是import引入外部样式表再配合-moz-binding等旧版私有属性来做脚本执行。虽然现代浏览器已经基本堵死这些老路子但你的内容如果还要被第三方爬虫、App 内嵌 WebView 展示情况就复杂得多。WebView 的内核版本有时候比较古老那些在 Chrome 里失效的向量在那里可能依然有效。所以我给 style 值设的关键字黑名单尽量保守。还有一类针对清洗器的攻击叫mXSSmutation XSS原理是利用浏览器解析的容错性某个 payload 在清洗器看来是安全的但浏览器在 DOM 树构建过程中会发生标签自动修正比如把noscript里的内容重新解析最终生成一个包含恶意属性的新节点。html5lib 这类遵循 WHATWG 规范的解析器对这种问题有较强的抵抗力这也是我坚持选 bleach 而不是用简单正则清洗的另一个原因。4.3 误杀也是事故优雅处理合法内容的清洗问题讲完攻击面必须聊聊误杀。富文本清洗最惹人怨的就是运营粘贴完的内容提交后格式没了。最常见的有三种情况。第一种是用户粘贴 Word 或者网页内容里面带大量内联样式这些样式我们全部 strip 掉了导致排版崩溃。解决方案不是放开 style 限制而是接受清洗后格式必然损失一部分这个现实并在编辑器层面引导用户使用标准工具栏排版而不是直接从外部粘贴。第二种是合法的空段落标签。pbr/p被清洗保留没问题但有的清洗器会把空p当无意义标签删掉结果用户每段之间大量的空行全部消失文章挤成一团。应对方式是在清洗规则里显式允许br标签并标记p为允许空内容。第三种是链接的可访问性。给a强制加上relnoopener noreferrer一定会让某些运营抱怨我链接怎么多了几个属性。但如果用户不知道这是为了防钓鱼攻击你就需要在前端编辑器里做提示或者干脆在后端统一添加并让前端忽略显示。误杀问题本质上是产品策略问题你要在安全性和编辑体验之间找一个团队能接受的平衡点。我的建议是安全底线一步不让但清洗后给前端返回一个清洗结果提示告诉用户哪些内容因为安全策略被调整了。用户体验的损失用透明度来补。5. 测试用例设计与上线前检查清单验证器本身也需要验证写代码的人都懂一个道理光有实现没有测试等于没有实现。富文本清洗逻辑这种安全敏感型代码尤其需要一套覆盖面足够的测试用例来保证清洗结果不会随着依赖库升级而悄咪咪变差。5.1 构造一套可复用的恶意样本集我习惯把这些样本放到项目的测试目录里起名叫xss_payloads.json内容就是一个巨大的字符串数组。这里分享一部分高频样本你可以直接复制进自己的测试里类别输入样本期望清洗后的结果基础脚本scriptalert(1)/script无 script 标签事件注入img srcx onerroralert(1)img无 onerror 属性协议注入a hrefjavascript:alert(1)x/aa的 href 属性被移除编码绕过a hrefjava#x73;cript:alert(1)x/ahref 被移除SVG 载体svgscriptalert(1)/script/svg无 svg 标签iframe 嵌套iframe src//evil.com/iframe无 iframe 标签style 注入div stylebackground:url(javascript:alert(1))x/divstyle 被移除HTML 注释hello !-- scriptalert(1)/script --注释被移除保留 hello畸形嵌套a hrefjavascript:alert(1)x/ahref 被移除空段落p/ppbr/p空段落保留 br注意上面这些样本统一都是清洗后不允许出现可执行代码但你的测试断言不能只判断结果里没有 script 标签因为攻击者可以改头换面。更稳妥的做法是断言清洗结果里不存在任何标签、属性、协议不在白名单列表里的节点。用 bleach 这类库的好处是它的返回结果已经是规则执行完毕的产物你的测试只需要验证结果等于调用一次同样的清洗函数处理干净样本得到的结果。5.2 单元测试如何组织才能防止回归单个样例断言意义有限真正有用的是幂等性测试。清洗函数应该具备幂等性对一段已经清洗过的内容再清洗一遍结果必须保持不变。这个特性一旦稳定你就可以放心地把清洗放在多个环节比如保存前一次、读取时再验一次不必担心二次清洗产生内容漂移。用 pytest 写起来大概是这样import pytest from your_module import sanitize_rich_text MALICIOUS_SAMPLES [ scriptalert(1)/script, img srcx onerroralert(1), a hrefjavascript:alert(1)x/a, svgscriptalert(1)/script/svg, div stylebackground:url(javascript:alert(1))x/div, mathmtextstyleimg srcx onerroralert(1)/style/mtext/math, ] pytest.mark.parametrize(payload, MALICIOUS_SAMPLES) def test_rich_text_sanitizer_blocks_execution(payload): result sanitize_rich_text(payload) assert script not in result assert onerror not in result assert javascript: not in result.lower() assert onload not in result def test_rich_text_sanitizer_idempotent(): base pHello a hrefhttps://example.comworld/a/p once sanitize_rich_text(base) twice sanitize_rich_text(once) assert once twice def test_legitimate_content_preserved(): content h2标题/h2p正文strong加粗/stronga href/page/1链接/a/p result sanitize_rich_text(content) assert h2 in result assert strong in result assert href/page/1 in result每次升级依赖库版本跑一遍这套用例你就能知道新版 bleach 或 jsoup 行为有没有变化。我遇到过 lxml 小版本升级导致 html5lib 清洗结果和之前有细微差异的案例没有回归测试几乎不可能发现。5.3 上线前逐项过一遍的检查清单最后这份检查清单是我在每次上线富文本相关功能前都会逐项打勾的直接分享出来清洗逻辑是否写在服务端接口层而不是前端代码里。白名单是否覆盖了编辑器实际用到的全部标签和属性并且没有把事件属性、协议属性遗漏进白名单。是否对 href/src/style 属性做了协议和关键字二次校验。清洗结果是否做过长度限制拒绝超大 payload。CSP 是否已配置object-src none是否落到位。富文本内容如果允许嵌入视频、图片资源域名是否有限制。输出模板是否区分了 HTML 上下文和属性上下文属性插值处是否转义。评论、昵称、签名、个人简介里如果也用了富文本是否复用了同一个清洗函数。是否有一套回归测试用例并且会在每次依赖升级时跑起来。运营团队的粘贴需求是否已经约定好清洗会损失部分格式的预期。这套清单不是我一次性想出来的是踩过坑之后一条条补上的。刚开始做富文本验证的时候我也觉得用 bleach 洗一下就算完事了直到在一次实战里发现攻击者利用反斜杠混淆绕过了协议校验才意识到清洗这个动作必须站在攻击者的角度去持续打磨。我把这些写下来是希望你少走我走过的那些弯路。验证富文本字段没有一劳永逸的银弹它是一套需要维护的策略组合服务端白名单清洗是地基CSP 是承重墙输出转义是应急出口而回归测试就是整个屋子里的烟雾报警器。四者缺一不可少一层你就是在赌攻击者不会正好打中那个缺口。
返回列表