接到类似标题的需求很常见,这类"安全方向"的项目写进简历,面试官的第一反应往往不是问你会不会调库,而是先问你到底懂不懂攻击原理。这篇我就把 DOM 型 XSS 这件事拆开讲清楚,先用两个框架的真实案例把漏洞演出来,再拿一个可复用的过滤组件收尾,最后聊聊这部分经验在简历和面试里到底该怎么包装才不露怯。
1. DOM 型 XSS 到底是什么,为什么面试官爱问这个
1.1 它和反射型、存储型 XSS 的本质区别
很多前端同学能把 XSS 的三种类型背出来,反射型、存储型、DOM 型,但一被追问"DOM 型 XSS 的攻击载荷在哪里被执?" 就卡壳了。这里给你一个一劳永逸的理解方式:反射型和存储型的恶意脚本都会经过服务器,要么是服务端在响应里直接拼接了攻击代码,要么是数据库里存的毒数据又被原样吐给浏览器,服务端和客户端之间有一个"中转站",所以传统的后端过滤、WAF 还能拦一下。
DOM 型 XSS 就不一样了,恶意载荷从头到尾就不需要碰服务器。问题出在前端代码自己把不可信的数据,比如地址栏里的查询参数、localStorage里被污染的内容、postMessage传来的消息,直接塞给了innerHTML、document.write、eval这类会"执行"字符串的 DOM API。服务器收到的只是正常的页面请求,返回的 HTML 也是干净的,但浏览器在本地执行 JavaScript 的时候,前端代码自己把攻击代码"翻译"成了活的 DOM 节点。这也是为什么很多人管它叫"前端漏洞",它的检测和防御必须从前端下手。
我见过不止一两个项目,后端 Java 层做了非常严格的参数校验和输出编码,结果安全测试报告里还是躺着一个高危的 DOM 型 XSS。排查下来发现是某个老页面从window.location里直接取了参数,拼了一段安心内容丢给innerHTML。这就是典型的前端自己把门打开的例子,怪不得别人。
1.2 简历上写"安全优化",千万不能只写这五个字
很多同学简历里有一句"处理了 XSS 安全问题",这是非常典型的无效描述。面试官看到只会觉得你想给自己贴一个安全标签,但完全看不出你做了什么、怎么做的、结果怎样。在写简历之前,你脑子里至少要有这些可量化的答案:你是在哪个业务场景发现的这个问题,用哪种攻击载荷验证过的,修复后是怎么确认漏洞被堵住的,这个修复方案能不能推广到其他项目。
标题里的"2 个框架案例 + 1 个过滤组件"其实是很好的简历写法,因为它有结构,有工作量,有通用性。两个框架案例证明你不是只会背概念,而是能在具体技术栈里发现问题;一个过滤组件说明你有抽象能力,能把防御方案沉淀成可复用的资产。你真正要准备的,是把这三个东西讲出技术细节,本文后面就会一步步带你把细节补齐。
2. 框架案例一:Vue 3 项目里 v-html 的 XSS 漏洞与防御
2.1 v-html 为什么会成为高危入口
Vue 模板默认会对绑定数据进行转义,比如{{ userInput }}里的<script>会被渲染成纯文本,不会执行。这个默认行为保护了大多数场景。但 Vue 提供了一个"逃生舱口",v-html,它的作用是直接把字符串作为 innerHTML 插入,完全绕过转义。当你把用户可控的数据丢给 v-html 时,恶意标签就活了。
这里有个很容易被忽略的点,<script>标签通过 innerHTML 插入其实是不会执行的,但很多人就以为"那没风险了"。真正的风险在于<img src=x onerror=alert(1)>这类带事件属性的标签。innerHTML 插入后,img 元素在加载失败时触发 onerror,JavaScript 照样执行。所以 v-html 的高危不是说一定弹窗给你看,而是任何带事件属性、带 href 恶意协议的标签都可能成为攻击载体。
我在实际代码里见过这种写法:用户发帖的时候允许填一个"个人签名",历史数据里存了一段 HTML,前端为了省事直接用 v-html 渲染。一开始安全测试也没发现,直到有人把签名写成<a href="javascript:alert(document.cookie)">点我</a>,这玩意儿虽然没有触发自动执行,但诱导用户点击一下,cookie 就飞了。这种"点击型"的 XSS 最难防,因为代码层面看起来完全没有自动执行的动作。
2.2 在 Vue 3 中做防御的正确姿势
最理想的方案当然是不用 v-html,展示富文本内容时优先考虑用支持白名单渲染的富文本编辑器来输出。但现实项目中老数据迁移、运营后台复用、需求要开放部分 HTML 标签,你没办法百分之百绕开它,这时候就得给 v-html 加一道"净化工序"。
这里分享一个 Vue 3 项目中通用的防御套路,核心是"先过滤,再渲染"。
import DOMPurify from 'dompurify' // 可以进一步提升安全性,把链接协议也限制住 DOMPurify.addHook('afterSanitizeAttributes', function(node) { if (node.tagName === 'A') { const href = node.getAttribute('href') if (href && href.startsWith('javascript:')) { node.removeAttribute('href') } } }) export function safeHtml(content) { return DOMPurify.sanitize(content, { // 白名单之外的标签全部移除 ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'], ALLOWED_ATTR: ['href', 'title', 'target', 'rel'], }) }使用的时候把这一层放在 v-html 的绑定值位置:
<template> <div class="signature-content" v-html="safeHtml(userSignature)"></div> </template>注意safeHtml如果直接写在模板里,每次渲染都会执行一次净化,性能上有浪费。更好的做法是在 setup 里用computed包一层:
<script setup> import { computed } from 'vue' import { safeHtml } from '@/utils/safeHtml' const props = defineProps({ userSignature: String }) const renderContent = computed(() => safeHtml(props.userSignature)) </script> <template> <div v-html="renderContent"></div> </template>这还没完,防御 DOM 型 XSS 不能只堵一个口子。如果你项目里还允许用户传入 style 属性,比如ALLOWED_ATTR里加了style,那你就额外开启了一个攻击面,CSS 注入、UI 覆盖、钓鱼风险都会冒出来。我的经验是,展示型内容一律不允许 style 属性,能用 class 解决的就用 class 解决,过滤组件里直接把这个属性从白名单里拿掉。
2.3 关于 Vue 2 到 Vue 3 的迁移注意点
如果你维护的是 Vue 2 老项目,升级到 Vue 3 的时候一定要注意,Vue 3 对 v-html 的处理在推荐写法上其实是"宁缺毋滥",官方文档里也明确提示过要谨慎使用。而在 Vue 2 中,this.$refs.someEl.innerHTML = html这种操作在很多老代码里也出现过,迁移 Vue 3 时这些直接操作 DOM 的地方不会自动帮你报错,但风险实打实还在。
另外 Vue 3 的常用生态里,某些组件库的富文本预览、markdown 渲染组件,底层同样是 v-html。你要做的不是信任组件的文档,而是去看组件源码里有没有做安全过滤。我自己就遇到过某个 markdown 组件,明明支持了 XSS 过滤,但默认配置没有开启,需要手动传sanitize: true选项,不看源码根本发现不了。这里也提醒各位,凡是引入第三方渲染类组件,第一件事就是去源码里搜innerHTML和v-html,确认它的安全处理策略。
3. 框架案例二:React 项目中的 dangerouslySetInnerHTML 与安全封装
3.1 dangerouslySetInnerHTML 不是普通 API,是"免责声明"
React 对 XSS 的默认防护其实做得相当好,JSX 里渲染文本内容时,任何字符串里的 HTML 标签都会被转义成普通文本。比如你写<div>{userInput}</div>,用户输入里的<script>会原样显示出来,不会执行。React 团队在这个层面直接把大多数 XSS 挡在了门外。
但dangerouslySetInnerHTML这个 API 的存在就是一个"我警告过了"的免责声明。它的名字就已经很直白了:这个操作很危险。它的作用是把一段 HTML 字符串通过 innerHTML 方式直接插入 DOM,React 完全不做任何转义和过滤。只要攻击者能控制传入的字符串,你的组件就裸奔了。
真实项目里最常见的坑有两个:第一个是把服务端接口返回的富文本内容直接交给 dangerouslySetInnerHTML,虽然这个内容是运营人员编辑的,但如果运营后台本身没有严格过滤,被上传了恶意脚本的文案会经过接口原样进来;第二个是把 URL 参数拿来拼 HTML 片段再渲染,比如详情页需要读取某个查询参数拼一个欢迎横幅,拼完直接塞给 dangerouslySetInnerHTML。
这里提醒一句,React 里href="javascript:alert(1)"这类属性,React 16 之后会做协议校验,自动把 javascript: 开头的 URL 当作不安全值,输出成空字符串。这算 React 的兜底保护,但你不要依赖它,因为绕过手段层出不穷,SVG 事件、iframe srcdoc、CSS 表达式都曾经有过绕过案例。防御还是要建立在"输入的字符串不可信"这个前提下。
3.2 封装一个安全的富文本渲染组件
我在 React 项目里常规做法不是每个地方都调 DOMPurify,而是封装一个<SafeHtml>组件,让团队里所有需要渲染富文本的地方统一使用,从组件层就把危险路径堵死。下面这个案例是实际项目中可以直接改改用的。
import DOMPurify from 'dompurify' import { memo, useMemo } from 'react' const DEFAULT_CONFIG = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'h2', 'h3'], ALLOWED_ATTR: ['href', 'title', 'target', 'rel'], } function SafeHtml({ html, className, config }) { // 用 useMemo 保证只有 html 或 config 变化时才重新净化 const cleanHtml = useMemo( () => DOMPurify.sanitize(html, { ...DEFAULT_CONFIG, ...config }), [html, config] ) return <div className={className} dangerouslySetInnerHTML={{ __html: cleanHtml }} /> } export default memo(SafeHtml)这里有一个容易被忽略的细节:useMemo的依赖数组里如果塞了config对象,而调用方每次渲染都传一个新的对象字面量,会导致净化重复执行。React 官方建议是让调用方把配置常量提取到组件外部,或者在组件内部用useMemo再把 config 缓存一下。我在实际项目里干脆不让调用方传 config,安全配置统一在组件内部维护,暴露一个variant属性,比如text、article、full,每种 variant 对应不同的白名单,这样既灵活又不容易被滥用。
使用层面也要立规矩:凡是接口返回的富文本字段、运营配置的 HTML 内容、用户生成的签名/简介,一律用<SafeHtml>,代码评审时看到dangerouslySetInnerHTML直接出现在业务组件里就要求打回重写。团队规范只有落到工具层,才能真正被执行。
3.3 别忽视 React 服务端渲染场景
如果项目用了 Next.js 或者其他 SSR 框架,DOM 型 XSS 的风险会特别隐蔽,因为服务端直接输出 HTML 给浏览器。原本前端过滤组件在浏览器里跑得好好的,到了 SSR 环境,DOM 可能不存在,或者组件在服务端渲染时根本来不及执行过滤逻辑。
DOMPurify 本身依赖 DOM API,在 Node.js 环境里不能直接使用,必须配合 jsdom 之类的库。如果你直接在 SSR 组件里 import 浏览器端的过滤模块,大概率会报window is not defined。
处理方案是在组件里判断环境,服务端启用 jsdom 的 DOMPurify 实例,浏览器端用默认实例:
let purify if (typeof window === 'undefined') { const { JSDOM } = require('jsdom') const window = new JSDOM('').window purify = DOMPurify(window) } else { purify = DOMPurify }不过这会给服务端带来额外的性能和内存开销,jsdom 实例不是免费的。如果富文本渲染只出现在纯客户端页面,就不要在服务端跑净化工序;如果确实需要在服务端输出安全 HTML,可以考虑把净化逻辑放到 Node 服务层,而不是 SSR 框架里。
4. 过滤组件:为什么是 DOMPurify,以及怎么封装才顺手
4.1 为什么不建议自己写过滤函数
很多前端同学遇到 XSS 过滤的第一反应是自己写一段正则,把<script>、onerror、javascript:这些关键词替换掉。我在代码评审里真的见过这种东西,用replace(/<script>/g, '')穷举拦截关键字。这种方案连入门级都算不上,因为绕过方式太多了,嵌套标签、大小写混合、编码混淆、未闭合标签、SVG 命名空间、事件属性动态构造,改一个姿势就能轻松穿透。
净化 HTML 的本质是一个"解析-过滤-重组"的过程,你必须真正把 HTML 解析成 AST,按标签和属性白名单做决策。DOMPurify 就是建立在浏览器原生 DOMParser 之上的,它不需要正则去猜,而是用浏览器自己的解析引擎去理解输入内容,然后把白名单之外的节点删掉。这也是为什么它能把各种浏览器特有的解析怪癖都考虑进去,因为它的底层就是浏览器本身。
DOMPurify 在 GitHub 上有十几万 star,长期接受安全研究者的审查和漏洞上报,这个信任度是一个自研工具完全无法比拟的。信我一句话,安全和加密一样,永远是"别自己造轮子"。
4.2 默认配置太开放?教你收紧过滤规则
DOMPurify 的默认配置其实比较宽松,ALLOWED_TAGS包含的标签非常多,应用的属性也没做严格限制,它更像一个通用消毒器。但要放到你自己的项目里,白名单一定要按业务需求去收紧。我的经验是从"最少必需集"开始,缺了再加,而不是上来就用全量默认。
举几个实际项目中会踩到的配置细节。
第一,ALLOWED_URI_REGEXP要自己定义。DOMPurify 默认允许的 URL 协议里有http、https、mailto、tel等,比较安全,但如果你在配置里放开了自定义协议,就得自己确认协议列表里没有javascript:和data:的可执行变体。
第二,FORBID_ATTR可以补充一些已知危险属性,比如style、formaction、xlink:href。如果业务实在需要样式,也不要用 style 属性,让运营去写 class,前端维护一份白名单 CSS 类。这个策略在内容安全上能省很多心。
第三,ADD_TAGS要谨慎使用。markdown 渲染里常用的代码高亮会引入span标签,如果业务场景里有类似需求,可以精确地把span放进来,但只允许class属性,尽量避免style。
第四,钩子函数。DOMPurify 的addHook可以让你在过滤前/后对 AST 做精细控制,比如我在项目里就通过afterSanitizeAttributes钩子给所有<a>标签强制加上rel="noopener noreferrer",防止 phishing 和 tabnabbing 攻击。这种追加操作在纯配置里做不了。
4.3 封装组件时的性能与缓存策略
净化并不是一个廉价操作,DOMPurify 解析和过滤一个长 HTML 字符串的时间大概在几百微秒到几毫秒之间。如果页面里有几十条富文本评论,每条都做全量净化,性能损耗会非常明显。特别是滚动加载的场景,一屏出现几十条内容,净化全部跑一遍,肉眼能感觉到卡顿。
我的方案是加两层缓存。第一层是模块级 Map 缓存,相同的输入字符串直接返回净化结果;第二层是框架层 useMemo,只在依赖变化时重算。另外还有一种更省的做法,服务端在数据写入前先净化一次,前端拿到手的数据已经是安全的。但这种方案只适用于内容产生端可控的场景,比如运营后台,不适合用户生成内容的实时场景。安全永远建议纵深防御,如果数据写入时已经净化,前端展示时再纯化一次也不是坏事,无非是性能换安全。
const cacheMap = new Map<string, string>() export function safeHtmlWithCache(html: string, config?: Config): string { const cacheKey = config ? JSON.stringify(config) + html : html const cached = cacheMap.get(cacheKey) if (cached) return cached const result = DOMPurify.sanitize(html, config) // 注意:缓存不能无限增长,这里简单限制一下容量 if (cacheMap.size > 200) { cacheMap.clear() } cacheMap.set(cacheKey, result) return result }这个缓存有个细节,输入字符串携带用户特征的时候,比如同一个评论内容在两次操作里带上了不同 token,会导致缓存爆炸。所以实际使用里我都是把"净化后用于展示"的内容单独存一份,避免动态串进缓存 key。你要是项目里只是简单展示,不追求极致性能,不缓存也行,但如果要封装成通用组件给团队用,性能兜底必须做在前面。
5. 常见绕过案例与自查清单,帮你补齐防御盲区
5.1 五个真实业务里最容易遗漏的攻击面
平时给团队做安全培训的时候,我列过一份"最容易翻车"的清单,这里剔除掉重复的,留下五个值得你重点自查的点。
第一,postMessage消息处理。页面通过window.addEventListener('message')接收其他页面传来的数据,如果没有校验event.origin,攻击者可以从任意域名 post 恶意消息过来。这里面不光是 HTML 注入,还有可能是消息内容直接进入了前端路由参数、存储数据后又参与 DOM 渲染,间接激活 DOM 型 XSS。处理 message 事件的第一行就应该是白名单校验 origin。
第二,location直接取值。location.search、location.hash的内容拿来拼 URL、拼 HTML、传给后端做分享页,这是老生常谈但永远有人在犯。攻击者构造一个恶意链接发给受害者,一旦受害者点击,页面就从地址栏读到了攻击载荷并渲染。防这个只需要记住一条铁律,地址栏的一切内容都是不可信的。
第三,window.name跨页传递数据。这个常见于老项目里的页面跳转数据传递,window.name在页面刷新和跨域跳转后仍然保留,攻击者可以在自己的页面里把window.name设置成恶意脚本,再跳到你的站点,你的代码读取window.name时就被污染了。sessionStorage都比它安全,起码不会跨源携带。
第四,富文本编辑器粘贴场景。用户从网页复制内容粘贴到编辑器,粘贴的内容里可能带各种危险标签。前端过滤往往关注编辑器输出,但粘贴入口同样要加消毒逻辑。很多项目在编辑器里看不到 XSS,一粘贴就攻击成功,就是忽略了paste事件。
第五,JSONP 回调参数。老项目用 JSONP 跨域取数据,回调函数名经常直接从 URL 参数里取,如果项目里用eval动态执行回调,恶意载荷就顺理成章地执行了。这个已经属于代码设计层面的高危,见到eval就应该警惕。
5.2 用一份自查清单守住前端安全底线
我在团队里推行过一张防御自查清单,每次代码评审的时候对照看一遍,比拿着安全测试报告再返工高效得多。这里把清单分享出来,你可以直接抄到自己的项目里。
| 检查点 | 合格标准 | 不合格示例 |
|---|---|---|
| v-html / dangerouslySetInnerHTML | 只出现在封装的安全组件里 | 业务组件直接使用 |
| URL 参数 / hash 取值 | 经过校验和编码后才参与渲染 | 直接拼接进 innerHTML |
| postMessage 监听 | 校验 origin 白名单 | 不校验来源直接信任数据 |
| localStorage / sessionStorage 读取 | 读取时做类型校验和安全过滤 | 取出字符串直接渲染 |
| 富文本编辑器粘贴 | 粘贴入口有消毒策略 | 完全信任编辑器输出 |
| 动态生成 script / iframe | 不允许业务层出现 | 基于用户数据拼 URL 后注入 |
这个表看着简单,但真的能帮你把大部分漏网之鱼提前捞出来。你写简历的时候也可以说"我沉淀了一套前端 XSS 自查清单,在 code review 里落地使用",这是比单纯写"修复了安全问题"更有说服力的表达。
6. 简历加分与面试应对:把安全经验讲出技术深度
6.1 简历上这项经验该怎么排版和量化
标题里提到的"简历加分"其实很容易实现,但前提是你要把工作成果写得像一次"有方法论的安全治理",而不是顺手一改的小补丁。我推荐在简历项目经历里单独列一条安全专项,描述尽量量化。
比较差的写法:"修复了项目中的 XSS 漏洞。"
比较好的写法:"针对项目富文本渲染场景的 XSS 风险,基于 DOMPurify 设计统一安全渲染组件,覆盖 Vue 3 与 React 两套技术栈,落地后业务代码中高危 innerHTML 调用减少 90%,并通过白名单配置与钩子函数完善了链接协议和事件属性防护。"
这中间的数字不要造假,如果你没有真实数据,不写数量,写覆盖面也行。比如"覆盖了全部富文本展示场景""渗透测试中相关风险点清零"这类能验证的表述,面试官看了更容易留下印象。
另外一个技巧是,简历上写"XSS 防御"时顺手带上"攻击原理"。你只有在真正理解了攻击原理之后,写出的描述才会自然带上载荷、绕过、净化这类术语。面试官从这些术语里能判断你是真的做过,还是背了概念。
6.2 面试官最爱追问的三个方向
准备好项目描述之后,面试官大概率会从三个方向往深挖。第一个方向是原理验证,比如"你给我演示一下 DOM 型 XSS 的攻击构造",这时候你至少能现场写一个?name=<img src=x onerror=alert(document.cookie)>的例子,并说清楚它被执行的过程。第二个方向是方案对比,比如"你封装的过滤组件为什么用 DOMPurify,它和其他方案比好在哪里",你要能讲清楚"浏览器解析引擎 + 白名单 + 钩子"这几个核心机制。第三个方向是工程化落地,比如"你推这个安全组件时,团队里有人反对吗",这时候就要谈谈你在 code review 里怎么立规范、怎么处理历史存量代码、怎么让团队成员接受新的开发习惯。
我面试候选人时遇到一个很棒的加分回答,他说他在项目里给每个富文本渲染点写了 Jest 测试用例,攻击载荷过一遍过滤器,断言输出里不包含危险标签。当场就把安全工程从"口头的"变成了"可回归的"。所以如果你想让这个项目真正成为简历亮点,务必补充测试用例,这个是性价比非常高的动作。
6.3 一个小技巧,让安全经验变成架构能力
最后分享一个让安全项目从"工具使用"升级为"架构设计"的思路。你可以不只停留在封装组件这个层面,而是去梳理前端项目里所有不可信数据的流入路径,画一张"数据流 + 危险操作"的对应关系表。比如地址栏参数有哪些地方在用、postMessage 有哪些监听器、localStorage 有哪些读取后直接渲染的代码。这张表本身就是你安全治理工作的交付物,面试时拿出来一讲,效果远胜于只说"我封装了一个组件"。
我在前面多次提到的自查清单其实是这张表的简化版。另外你还可以往这个方向加一个工程化动作,把不符合安全规范的代码在 CI 阶段就拦截掉,或者给 lint 规则加一条警告。这些加在一起,就是一个完整的"前端安全治理"故事,已经完全配得上"简历加分"这四个字了。