DOM-based XSS攻击原理与前端防御实战

轩辕可歆 安全 阅读 2,452
赞 16 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个营销页,用户反馈“点开白屏3秒”“滑动卡顿”“有时候直接闪退”。我一开始以为是图片太大或者接口慢,查了半天发现根本不是——页面里压根没发几个请求,首屏渲染完之后,滚动、点击、输入框聚焦全都延迟半秒以上。最离谱的是,我在控制台打个 console.log('test') 都要等一两秒才输出。这哪是性能问题,这简直是 DOM 被下了蛊。

DOM-based XSS攻击原理与前端防御实战

后来扒了下代码,发现整个页面的文案、按钮文案、弹窗内容全是靠 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.writeElement.innerHTML 后面那一长串 Recalculate StyleLayoutUpdate 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,而是让它根本别执行。

具体做法就三步:

  1. 所有服务端可确定的内容,全部走 SSR 渲染(哪怕只是首屏),URL 参数只传结构化数据,比如 ?dataId=abc123
  2. 前端用 fetch 拉 JSON,JSON 字段严格限定为 titledescctaText 这类纯文本字段;
  3. 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 库),欢迎评论区聊聊。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论