资讯详情

从输入过滤到上下文编码:Lucy-XSS Filter 的设计与实现

📅 2026/10/17 16:46:12 | 华诺云谱 👁 阅读
从输入过滤到上下文编码:Lucy-XSS Filter 的设计与实现
去年我们组在处理一段用户评论的富文本渲染时连续两轮安全测试都被 XSS 问题挡了回来。也就是从那时候起团队把 Lucy-XSS Filter 项目正式独立了出来专门解决“用户输入的字符串进到页面之后变成可执行代码”这一类问题。Lucy-XSS Filter 本质上是一套 XSS 过滤器它接收不可信的原始输入按使用场景区分 HTML、属性、URL、文本等上下文再输出一份相对安全的可插入页面内容。如果你正在做 Web 安全加固、表单组件、评论或富文本模块或者只是被安全测试报告里的 XSS 风险折腾过这篇教程里的设计思路、实现步骤和踩坑记录基本能帮你少走一半弯路。1. 项目定位为什么需要一套自己的 XSS 过滤器1.1 XSS 攻击的主要形态和过滤的必要性XSS 的全称是跨站脚本攻击核心描述一句话攻击者想办法让受害者的浏览器执行一段原本不属于当前页面的脚本。按最终触发位置不同常见分类有三种反射型、存储型和 DOM 型。反射型通常是一次性拼接进响应页面比如搜索框参数原样回显存储型更危险攻击载荷被保存在服务器端所有访问该内容的用户都会中招比如评论区、昵称、个人签名DOM 型则发生在浏览器端脚本里数据不经过服务器直接把 location、postMessage 或者其他来源的字符串插进 innerHTML。传统的防护手段有很多层但最基础、覆盖面最广的还是内容过滤。服务器端做输入校验可以拦掉一部分明显异常的数据但浏览器端也必须有一道防线。原因不复杂前后端分离之后前端拿到的不一定都来自自家接口就算后端严格过滤也保不齐有导入导出、富文本编辑器、第三方接口这些旁路把脏数据带进来。Lucy-XSS Filter 就定位在这道浏览器端防线上同时规则设计成可以复制到服务端复用。真正做过安全的人会有个共识单纯靠过滤器不能解决所有安全问题但一个好的过滤器能把大部分常见风险挡在门外。它能处理的场景包括清理用户输入的富文本、在拼接 HTML 模板前转义变量、限制可用的标签和属性、过滤危险协议等等。1.2 Lucy-XSS Filter 的功能边界做安全组件最怕的就是边界感不清。Lucy-XSS Filter 在设计初期我们就明确了两件事它做什么、它不做什么。它做的事情有四类。第一输入规范化把常见编码变体统一还原比如 HTML 实体、大小写混合、十六进制写法。第二标签和属性白名单过滤只保留明确允许的 HTML 元素和属性。第三危险内容剥离包括 on* 事件属性、javascript: 协议、CSS 中的 expression 写法等。第四上下文编码输出针对不同位置调用不同的编码策略保证插入到 HTML 文本、属性、URL 或 JS 字符串里的内容安全。它不做的事情同样重要。Lucy-XSS 不做业务语义判断比如“这句话是不是垃圾广告”那是业务风控的事它也不做密码、身份证号这类敏感信息的脱敏因为脱敏必须按照业务规则来做过滤组件掺和进去容易把数据改坏。它更不替代 CSPContent Security Policy这两者是互补关系CSP 是浏览器层面的安全策略过滤器是数据层面的清理可以同时开。边界定得清楚后面开发时就不会出现“为什么这个组件连这个都不管”的争论。凡是边界外的需求统一走独立模块或业务代码。1.3 为什么选择自研而不是直接用现成组件立项时团队也讨论过要不要直接用一个现成的 sanitizer 类库。客观说成熟库维护时间长、漏洞积累少这些都是优势。但对比之后我们发现现成方案放在实际项目里并没有想象中顺手。首先是定制粒度的问题。安全需求一旦跟业务耦合比如某块内容必须保留特定标签现成库要么规则写起来很别扭要么直接不支持。其次是体积和兼容性。为了一句话文本过滤引入整个清洗器对包体积敏感的项目很难接受。再次是审计可控性。安全组件需要能随时根据新出现的攻击手法快速调整规则自研组件团队内每个人都清楚规则是怎么流转的出了问题也容易定位。当然自研的前提是团队有足够的安全意识和技术储备。如果完全没有基础优先建议站在成熟的、经过大量验证的库上去做封装而不是从零造轮子。Lucy-XSS Filter 之所以最终自研是因为我们目标是长期维护整个前端安全基础过滤器只是第一步。2. 过滤机制的核心设计2.1 先想清楚输入过滤比输出编码优先级更高吗在设计 Lucy-XSS Filter 时团队内部争论最多的一个问题是过滤应该放在输入端还是输出端。我的结论是输入端过滤是主力输出端编码是底线两者必须同时存在不能互相替代。输入过滤的价值在于数据从源头变干净。用户提交的script进数据库之前就被去掉后面任何使用这个数据的地方风险都会小很多。但输入过滤有个固有缺陷过滤手段无法预测所有未来场景。比如业务方新增了一个输出位置原本认为无毒的“安全字符串”放到新上下文里可能又成了可执行结构。所以单纯过滤不够输出编码必须跟上。输出编码的思路不是去掉危险字符而是把危险字符转换成无意义的等价物。比如转成lt;浏览器渲染时会显示字符但不会把它当标签解析。编码策略还要看上下文HTML 文本、属性值、URL、JavaScript 字符串编码规则各不相同。Lucy-XSS 的设计顺序是输入规范化 → 过滤 → 编码。过滤做减法和删除编码做转义和替换。两层配合才是纵深防御这也是项目从一开始就坚持的架构。2.2 白名单为主黑名单只做补充过滤规则制定时原则很明确尽量用白名单黑名单只作为辅助。所谓白名单是指明确定义哪些标签、属性和协议是被允许的其他一律删除。黑名单正好反过来定义哪些是危险的然后逐一剔除。黑名单的问题在于你永远追不上攻击者。今天你知道 onerror 危险把onerror拦截了明天攻击者可能用onerror的大小写混淆、编码变换、SVG 里的动画属性绕过你的黑名单就得不断打补丁。白名单则不同只要允许列表里没有 onerror无论它怎么变换最终都会被丢弃。Lucy-XSS Filter 的白名单分三层标签白名单、属性白名单、协议白名单。层级示例允许项说明标签白名单p, div, span, a, strong, em, ul, ol, li, blockquote, code, pre, br, img只保留纯展示和结构类标签属性白名单href, src, alt, title, class, id, target, rel, width, height,>{ version: 1.0.0, defaultPolicy: { allowedTags: [p, div, span, a, strong, em], allowedAttributes: { *: [class, title], a: [href, target, rel], img: [src, alt, width, height] }, allowedProtocols: [http, https, mailto, tel], disallowTagChildren: { a: [a] } }, contextRules: { html: { encode: [text] }, attribute: { quoteChar: \, encode: [amp, quote, lt, gt] }, url: { allowSafeProtocols: true } } }配置中每个字段都有实际意义。allowedTags限定可用元素allowedAttributes按标签区分属性集合使用通配符*表示所有元素通用的属性disallowTagChildren用于处理标签嵌套约束比如 a 标签不能嵌套 a 标签。contextRules记录不同上下文使用的编码方式。这套配置后来被验证是合理的因为规则的修改不需要走代码发布只改配置就能覆盖大部分新需求。3. 从零实现 Lucy-XSS Filter3.1 模块划分与数据流Lucy-XSS Filter 的代码结构并不复杂划分为五个模块解析器、规则引擎、清洗器、编码器和报告器。解析器负责把输入字符串解析成 DOM 或 token 序列。规则引擎负责把解析结果和配置的白名单规则做比对清洗器执行真正的删除和替换生成一棵只包含安全节点的树编码器处理上下文编码输出最终字符串报告器记录所有被过滤的内容方便调试和安全审计。数据流的走向是线性的原始输入 → 解析 → 遍历检查 → 清洗 → 编码 → 输出。整体流程在同步函数中完成也可以包装成异步接口。之所以先解析成结构化对象再做过滤而不是用正则替换是因为正则很难处理 HTML 的嵌套结构和实体解码。举个简单例子输入scrscriptiptalert(1)/script正则直接匹配script会被中间的脚本干扰而结构化解析可以通过状态机正确处理。3.2 核心功能实现步骤下面给出 Lucy-XSS Filter 核心逻辑的简化版本方便说明实现链路。实际项目里会在这个基础上加上配置项和更多上下文分支。const ALLOWED_TAGS new Set([p, div, span, a, strong, em, ul, ol, li, br, img]); const ALLOWED_PROTOCOLS new Set([http, https, mailto, tel]); function sanitizeHTML(input, context html) { if (typeof input ! string) { return ; } const doc new DOMParser().parseFromString(input, text/html); const body doc.body; const walker doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); const nodesToRemove []; while (walker.nextNode()) { const node walker.currentNode; if (!ALLOWED_TAGS.has(node.tagName.toLowerCase())) { nodesToRemove.push(node); continue; } for (const attr of Array.from(node.attributes)) { const attrName attr.name.toLowerCase(); const attrValue attr.value; if (!isAllowedAttribute(node.tagName.toLowerCase(), attrName)) { node.removeAttribute(attr.name); continue; } if ((attrName href || attrName src) !isSafeProtocol(attrValue)) { node.removeAttribute(attr.name); } } } nodesToRemove.forEach(node node.remove()); if (context text) { return encodeText(body.textContent); } return encodeHTML(body.innerHTML); } function isAllowedAttribute(tag, attr) { const globalAllowed [class, title]; const tagAllowed { a: [href, target, rel], img: [src, alt, width, height] }; return globalAllowed.includes(attr) || (tagAllowed[tag] || []).includes(attr); } function isSafeProtocol(input) { try { const url new URL(input, window.location.origin); return ALLOWED_PROTOCOLS.has(url.protocol.replace(:, )); } catch (e) { return false; } }几个实现细节要特别说明。解析阶段用DOMParser而不是正则原因上面提过结构化解析对抗嵌套混淆更可靠。属性遍历时先把属性名toLowerCase()避免大小写绕过。URL 协议判断使用new URL()解析而不是字符串startsWith因为java\nscript:这类拼接和空白符号字符串判断容易漏。删除节点时先用数组收集再统一移除避免遍历动态变化导致漏删。3.3 编码函数实现编码是过滤之后最重要的防线。Lucy-XSS 的编码器按上下文拆分成多个小函数不搞一个大而全的转义函数。function encodeText(input) { return input.replace(/[]/g, char { const map { : amp;, : lt;, : gt; }; return map[char]; }); } function encodeAttribute(input) { return input.replace(/[]/g, char { const map { : amp;, : quot;, : #39; }; return map[char]; }); } function encodeURL(input) { return encodeURIComponent(input); }文本上下文只需要转义 、、属性上下文还要处理引号防止提前闭合属性URL 编码使用encodeURIComponent把参数中可能存在的特殊字符做整体编码。这里有一个容易出错的地方不要对已经编码的数据二次编码。如果上游已经输出lt;编码器再转一次会变成amp;lt;页面显示就会异常。所以 Lucy-XSS 设计的约定是所有进入组件的输入都视为原始字符串组件统一处理业务方不要再提前转义。3.4 在业务代码中的接入方式过滤器写好了接入要做成让业务方用起来不费劲。我们最终提供的是三种调用方式直接函数调用、框架辅助函数、以及上报回调。直接函数调用最基础适合任何项目import { sanitize } from team/lucy-xss; const safeText sanitize(scriptalert(1)/script, { context: html }); // 输出 const safeLink sanitize(javascript:alert(1), { context: url }); // 输出 const safeComment sanitize(hello bworld/b, { context: html }); // 输出hello bworld/b框架接入层只做一件事把组件能力绑定到模板指令。比如前端模板里写v-lucy-xss背后就是在绑定 innerHTML 之前自动调用 sanitize 函数。这个封装层很薄目的只有一个让开发人员写模板时默认就走过滤不用每次手动调用。上报回调用在审计场景。生产环境里我们希望知道哪些内容被过滤器拦掉了这些可能是攻击探测也可能是正常用户输入误伤。上报回调把过滤前的原始内容、过滤命的规则、时间戳记录到日志系统安全团队可以根据这些数据调整规则避免误杀。4. 测试用例设计和绕过手段排查4.1 构建基础测试用例集安全过滤器没有充分测试就上线等于裸奔。Lucy-XSS 的测试用例集分为三块常规功能用例、攻击载荷用例、编码变体用例。类别输入内容校验点常规phello/p保留 p 标签与文本常规hello strongworld/strong保留 strong不解析多余标签攻击scriptalert(1)/scriptscript 标签被移除攻击img srcx onerroralert(1)onerror 属性被移除攻击a hrefjavascript:alert(1)click/ahref 被移除或整个链路被判定不合法编码变体#106;#97;#118;#97;#115;#99;#114;#105;#112;#116;:解码后仍被协议白名单拦截编码变体a HREFJaVaScRiPt:alert(1)x/a大小写混合不绕过上下文文本上下文输入bbold/b按文本展示不渲染加粗用例集不能只写一次就扔在那。每次规则更新这些用例必须全量跑一遍回归。我们发现安全组件的 bug 很多不是在新增规则时引入的而是修改规则时把旧的边界放开了回归测试能快速兜住这类问题。4.2 常见绕过方式和对应修复测试过程中遇到的绕过手段非常有代表性结合这些案例说说 Lucy-XSS 是怎么处理的。第一种是大小写混淆和空格混淆。攻击者写sCrIpT或者img srcx onerror alert(1)在属性名中间插入空格。应对方式就是归一化处理所有标签名和属性名统一转小写属性匹配前先 trim 并压缩内部空白。第二种是 HTML 实体双重编码。攻击载荷写成#106;avascript:alert(1)先解码实体再检查协议。因此过滤顺序必须是先解码再过滤顺序反了就会被骗。第三种是 SVG 和 MathML 命名空间绕过。HTML 解析器对 SVG、MathML 命名空间下的标签解析规则与普通 HTML 不同一些在 HTML 里无效的绕过写法在 SVG 里是有效的。白名单里根本没有 svg、math、foreignObject 这些标签自然全被移除。这里有个经验白名单允许的标签越少命名空间绕过越难。第四种是伪协议和编码协议的混淆。比如data:text/html;base64,xxx、vbscript:、file:这些协议协议白名单直接拦截。但要小心data:image/png;base64,xxxx这种图片场景业务上可能真的需要保留。我们的办法是给 data 协议按 MIME 类型再做细化只允许data:image/前缀其他 data 协议一律拒绝。第五种是嵌套组合的载荷。比如a hrefx onclickalert(1)text/a单看某个属性没问题但组合起来就存在危险。Lucy-XSS 的修复方式是不仅过滤属性还会做关联校验a 标签出现事件属性时直接删除该属性如果 href 被判定为危险同时把该链接整体移除。4.3 性能和兼容性实测安全组件不能成为性能瓶颈。我们针对 Lucy-XSS 做了一组简单的基准测试在常规电脑浏览器上输入长度 1KB 的文本完整过滤流程耗时大约在 0.2~0.5 毫秒之间输入长度 100KB 的富文本耗时大约在 10~20 毫秒之间。这个量级对大多数业务场景都够用。如果遇到超大内容比如整篇文章的 HTML建议拆分处理或放到空闲时间执行。还可以做一层缓存对相同输入内容和相同上下文的调用直接返回上一次计算结果。要注意的是缓存必须严格绑定配置版本配置更新后缓存要立即失效否则旧规则产生的安全结果会被新页面沿用。兼容性方面现代浏览器对DOMParser支持都很完整服务端使用基于 Node 的 DOM 实现也能跑通同一套代码。老版本浏览器环境不支持的接口需要做降级处理此时需要回退到正则规则但正则方式的风险较高建议尽量避免。5. 接入过程中的常见问题速查5.1 场景化问题实录问题一富文本里图片不显示了。现象是img srcdata:image/png;base64,xxxx被过滤图片裂了。原因是默认协议白名单不允许 data 协议。解决方案有两种要么图片改走对象存储传正常 http/https 链接要么在配置里增加data:image/的白名单规则。我们最终选了配置方案但只允许 MIME 类型限定在 image 范围避免 data:text/html 混进来。问题二编辑器里的换行和空行全没了。现象是用户输入带有br但白名单里没配置 br 标签清洗后文本全部粘连成一大段。这类问题就是规则适用性问题把 p、br、blockquote 这些结构性标签补进白名单就解决了。它提醒我们团队在制定白名单初期就应该跟内容业务方对齐需要哪些标签。问题三部分页面出现了双重编码。现象是内容里出现了amp;lt;页面上看到lt;而不是。排查后发现问题不在过滤器而是业务方在调过滤前先手动做了一次 escape过滤器拿到已经是转义后的字符串又转义了一遍。解决办法是统一约定入口数据必须保持原始字符串任何转义操作都交给 Lucy-XSS 处理。问题四某个表单提交后服务端存储了过滤后的 HTML但页面渲染时又调了一次客户端过滤导致部分属性丢失。这个问题的本质是同一段数据被过滤了两次而两次规则不完全一致。方案是把过滤逻辑同时部署到服务端让入库和出库使用同一套规则集而不是客户端过滤一次、服务端再碰一次。5.2 快速排查流程如果线上出现 XSS 相关告警或者内容被过滤异常我们的排查流程基本固定为五步第一步确认数据来源。该字符串是来自用户输入、第三方 API 还是数据库读出的历史数据不同来源的信任级别不同。第二步确认数据流向。内容最终被插入到什么位置HTML 文本、属性、URL 还是 JavaScript 字符串。流向决定编码方式。第三步检查过滤规则。看 Lucy-XSS 的配置是否覆盖了该场景白名单是否存在误杀或漏放。第四步看报告器的审计日志。日志里记录了哪条规则命中了输入对照日志就能判断是规则问题还是输入本身异常。第五步复现并回归。把线上触发的内容提取出来跑一遍完整测试用例确认修复后又没有引入新问题。这套流程最核心的价值是不要凭感觉去猜。每一步都有数据支撑排查速度会快很多。6. 从项目落地过程中沉淀的几点个人经验做 Lucy-XSS Filter 这个项目给我留下最深的体会是安全组件的维护成本不在开发阶段而在规则演进阶段。最初我们以为把几个危险标签和事件属性拦掉就结束了真正跑起来才发现业务形态一变规则就要跟着变测试用例也要跟着更新。这件事必须形成固定机制而不是哪里漏了补哪里。另一个经验是规则配置尽量不要放进前端代码里。我们把默认配置打成独立文件放在发布包里但允许运行时从远程配置平台拉取增量规则。这个设计救过我们一次新出现的高危绕过方式只需要改远程配置不需要发版就能全端生效。但远程配置有风险必须有严格的变更审批和版本回滚机制否则配置发错影响的是所有页面。最后一个小建议如果你准备在团队里落地类似安全项目不要只交付代码和文档一定要把测试用例集一起交付。测试用例是安全逻辑的最直接表达后续任何人接手机制都能通过用例集快速理解边界在哪、哪些行为是允许的、哪些是必须拦截的。安全过滤这件事本质上就是不断跟不确定性较劲用例和配套的回归机制就是我们用来对抗不确定性的锚点。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑