DOM-based XSS攻击原理与前端防御实战
优化前:卡得不行
上周上线一个营销页,用户反馈“点开白屏3秒”“滑动卡顿”“有时候直接闪退”。我一开始以为是图片太大或者接口慢,查了半天发现根本不是——页面里压根没发几个请求,首屏渲染完之后,滚动、点击、输入框聚焦全都延迟半秒以上。最离谱的是,我在控制台打个 console.log('test') 都要等一两秒才输出。这哪是性能问题,这简直是 DOM 被下了蛊。
后来扒了下代码,发现整个页面的文案、按钮文案、弹窗内容全是靠 innerHTML 拼出来的,拼的来源是 URL 参数、localStorage 甚至 document.referrer。更绝的是,有段逻辑是这样写的:
const raw = new URLSearchParams(location.search).get('content');
document.getElementById('main').innerHTML = raw;
你敢信?用户随便在地址栏塞个 ?content=%3Cimg%20src=x%20onerror=alert(1)%3E,不仅 XSS 触发,而且每次 innerHTML 赋值都会触发一次完整的 HTML 解析 + DOM 构建 + 事件绑定 + 样式计算 —— 尤其当 raw 是几百行带内联样式和 script 标签的垃圾 HTML 时,Chrome 的 Performance 面板直接红成一片。实测首屏可交互时间(TTI)高达 5.2s,Lighthouse 性能分只有 28。
找到瘼颈了!
我先开了 Chrome DevTools 的 Performance 面板,录了 10 秒操作,点开火焰图一看,90% 时间耗在 HTMLDocument.write 和 Element.innerHTML 后面那一长串 Recalculate Style → Layout → Update Layer Tree 上。再切到 Memory 面板,强制 GC 后反复点“刷新内容”按钮,内存不释放,DOM 节点数直线飙升——明显是动态插入后没做清理,还带一堆没卸载的事件监听器。
接着用 window.addEventListener('DOMNodeInserted', ...) 监听节点插入,发现每次更新都新建了一整套 DOM 树,老节点没移除,新节点又挂上去,最后页面里藏着 7 个一模一样的 #main 容器……这里注意我踩过好几次坑:很多人以为 el.innerHTML = '' 就清干净了,其实它只清子节点,不会自动解绑事件或释放资源。尤其当里面有 或 onxxx 属性时,V8 会偷偷保留引用,GC 不掉。
试了几种方案
我试了三种路子:
- 用
textContent替代innerHTML—— 安全了,但所有富文本都变纯文本,产品说“文案带链接和加粗,不能砍”; - 引入 DOMPurify 做过滤 —— 安全+保留格式,但 Purify 本身解析 HTML 耗时严重,实测平均增加 180ms 渲染延迟;
- 改用模板字符串 +
createElement手动构建 —— 安全、可控、轻量,但写法啰嗦,维护成本高,团队新人看了直挠头。
折腾了半天发现,问题本质不是“怎么防 XSS”,而是“为什么非要用 innerHTML 动态渲染不可?”
最后这个效果最好
我们最终决定:把“动态渲染”这个动作彻底干掉。不是优化 innerHTML,而是让它根本别执行。
具体做法就三步:
- 所有服务端可确定的内容,全部走 SSR 渲染(哪怕只是首屏),URL 参数只传结构化数据,比如
?dataId=abc123; - 前端用 fetch 拉 JSON,JSON 字段严格限定为
title、desc、ctaText这类纯文本字段; - 用
textContent+setAttribute更新 DOM,完全避开 HTML 解析流程。
核心代码就这几行(拿一个按钮更新为例):
// 优化前(危险且慢)
const rawBtn = new URLSearchParams(location.search).get('cta');
document.querySelector('.js-cta-btn').innerHTML = rawBtn;
// 优化后(安全且快)
fetch('https://jztheme.com/api/content?dataId=' + dataId)
.then(r => r.json())
.then(data => {
const btn = document.querySelector('.js-cta-btn');
btn.textContent = data.ctaText || '立即参与';
btn.setAttribute('href', data.ctaUrl || '#');
});
这里有个关键细节:我们加了个极简的 fallback 机制。如果接口失败或字段缺失,就用预设的默认文案,而不是 fallback 到 URL 参数里拼 HTML——因为那玩意儿就是祸根源头。
另外,所有可能含用户输入的地方(比如从 localStorage 读取昵称展示),统一走 textContent,绝不进 innerHTML。连 title 属性更新都用 el.title = xxx,不用 el.setAttribute('title', xxx)(后者在某些旧版 Safari 会触发重排)。
优化后:流畅多了
改完上线,实测数据如下(真机 iPhone 12 + Chrome 124):
- 首屏可交互时间(TTI):从 5.2s → 0.82s(提升 84%);
- 滚动帧率(FPS):从平均 22fps → 稳定 58–60fps;
- Lighthouse 性能分:28 → 92;
- 内存占用峰值:从 142MB → 47MB;
- XSS 漏洞扫描结果:0 个 high/critical 级别漏洞(之前是 17 个)。
最爽的是,现在开发同学改文案再也不用担心“会不会 XSS”,也不用找我来 review 每一行 innerHTML —— 因为它已经从项目里消失了。
性能数据对比
我们还做了 AB 测试:同一批用户,A 组看老版本(innerHtml 动态渲染),B 组看新版本(纯文本 + 结构化 API)。结果 B 组的跳出率下降 31%,按钮点击率上升 22%。产品经理盯着数据看了三分钟,然后默默把“动态 HTML 渲染”从需求文档里删了。
当然,这方案不是万能的。比如需要支持用户粘贴富文本(比如活动规则里嵌 YouTube 视频),我们还是得上 DOMPurify,但会单独抽离模块、加 loading 骨架屏、限制最大字符数(5000 字以内),并加个 500ms 超时降级为纯文本。这个细节就不展开了,总之——能不用 innerHTML,就别用;非用不可时,把它关进笼子里,再加三道锁。
以上是我的优化经验,有更好的方案欢迎交流
这个优化花了我三天:一天定位,一天改代码,一天压测和对齐。过程中踩了不少坑,比如忘了清空旧的 setInterval 导致内存泄漏,还有某次误把 textContent 写成 innerText,结果 IE 下文字换行异常……都是血泪教训。
如果你也在处理类似场景,或者有更好的 DOM XSS 性能兼顾方案(比如 Web Component 方案、或者更轻量的 sanitizer 库),欢迎评论区聊聊。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论