公司动态

前端 XSS 防御:DOMPurify 与 CSP 的协同策略

📅 2026/7/28 21:44:20
前端 XSS 防御:DOMPurify 与 CSP 的协同策略
前端 XSS 防御DOMPurify 与 CSP 的协同策略一、富文本场景下的攻击面从存储型到 DOM 型Web 应用对用户输入 HTML 的需求一直存在富文本编辑器、评论系统、邮件模板渲染。只要允许 HTML 输入XSS 的攻击面就打开了。XSS 分三类攻击路径各不相同。存储型 XSS恶意脚本经表单写入数据库后续用户访问时从服务端读出并渲染到页面。典型场景是评论系统的昵称、个人简介字段。反射型 XSS恶意 payload 经 URL 参数传入服务端直接拼接到响应 HTML 中。典型场景是搜索结果页的查询参数。DOM 型 XSS完全不经过服务端恶意数据经 source如 location.hash、document.referrer流入 sink如 innerHTML、document.write。这类 XSS 的 CSP 防护难度最大因为数据从未离开浏览器。三种类型的共同点是不可信数据最终到达了能执行脚本的 sink。防御思路因此有两条线——净化输入DOMPurify与限制 sink 执行能力CSP。二者必须协同单独使用任一都有绕过路径。二、纵深防御管线净化层与隔离层的分工三层防御的管线如下每层各司其职存在重叠但不可互相替代不可信 HTML 输入 │ ▼ ┌─────────────────────────────────────┐ │ 第一层DOMPurify 净化 │ │ - 基于 parse5 解析为 DOM 树 │ │ - 按白名单删除标签/属性/协议 │ │ - 输出干净的 HTML 字符串 │ └──────────────┬──────────────────────┘ │ 净化后 HTML ▼ ┌─────────────────────────────────────┐ │ 第二层Trusted Types 强制门控 │ │ - innerHTML 等 sink 只接受 TT 对象 │ │ - 从根上阻断字符串直接注入 │ └──────────────┬──────────────────────┘ │ 类型安全 ▼ ┌─────────────────────────────────────┐ │ 第三层CSP 内容安全策略 │ │ - script-src 限制脚本来源 │ │ - 禁用内联脚本与 eval │ │ - 兜底拦截漏网脚本执行 │ └──────────────┬──────────────────────┘ │ ▼ 浏览器渲染DOMPurify 是白名单净化。它用浏览器原生解析器把 HTML 字符串解析成 DOM 树遍历每个节点不在白名单内的标签和属性直接删除。关键在于它处理的是 HTML 结构层面的威胁script 标签、onerror 属性、javascript 协议等。CSP 是执行约束。它通过 HTTP 响应头声明哪些来源的脚本可以执行。即使恶意脚本注入了 DOM只要其来源不在白名单内浏览器就拒绝执行。CSP 的 script-src self 能阻断外链脚本nonce 能放行带 nonce 的内联脚本。Trusted Types 是类型门控。它把 innerHTML 等 sink 从接受任意字符串改为只接受 TrustedHTML 对象。这意味着即使开发者不小心把不可信数据传给 innerHTML浏览器也会拒绝执行除非经过显式声明的 policy 转换。三者关系如下表防御层防御对象生效位置绕过风险性能开销DOMPurifyHTML 结构威胁应用层JS白名单配置错误中每次解析Trusted Typessink 字符串注入浏览器内建policy 漏洞低CSP脚本执行浏览器内建配置宽松极低三、生产级配置白名单、策略与上报// sanitize.ts // DOMPurify 配置白名单是核心必须最小化。 // 为什么不用 ALLOWED_TAGS: [*]通配等同于不防御。 // 为什么显式禁用 ALLOW_DATA_ATTRdata 属性可被 CSS 选择器 // 读取配合样式注入可做数据外泄。 import DOMPurify from dompurify; // 富文本评论场景的白名单 const COMMENT_CONFIG: DOMPurify.Config { ALLOWED_TAGS: [ p, br, strong, em, u, s, blockquote, ul, ol, li, a, code, pre, span, ], ALLOWED_ATTR: [href, title, target, rel], // 禁止所有 data 属性防止 CSS 属性选择器外泄 ALLOW_DATA_ATTR: false, ADD_ATTR: [rel], // 禁止协议白名单外的 scheme ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):)/i, // 钩子对每个 a 标签做安全加固 hooks: { afterSanitizeAttributes: (node) { if (node.tagName A) { // 防止钓鱼新窗口打开时禁用 opener 引用 node.setAttribute(target, _blank); node.setAttribute(rel, noopener noreferrer); const href node.getAttribute(href) || ; if (/^javascript:/i.test(href)) { node.removeAttribute(href); } } }, }, }; export function sanitizeComment(html: string): string { // 输入长度限制防止超大输入拖垮解析器 if (html.length 50_000) { throw new Error(输入超出长度限制); } const clean DOMPurify.sanitize(html, COMMENT_CONFIG); return clean; }// trusted-types.ts // Trusted Types 策略所有 sink 必须经过此 policy 转换。 // 为什么需要它DOMPurify 输出的是字符串 // 没有类型保证TT 把净化变为强制的类型契约。 let policy: TrustedTypePolicy | null null; export function getHTMLPolicy(): TrustedTypePolicy { if (policy) return policy; if (typeof trustedTypes undefined) { // 浏览器不支持 TT 时返回 shim 避免报错 // 但此时 sink 没有强制保护依赖 CSP 兜底 return { createHTML: (input: string) input as unknown as TrustedHTML, } as TrustedTypePolicy; } policy trustedTypes.createPolicy(app-html, { createHTML: (input: string) sanitizeComment(input), }); return policy; } // 统一的安全注入入口 export function safeSetInnerHTML(el: Element, html: string): void { const tt getHTMLPolicy().createHTML(html); el.innerHTML tt; }// csp-header.js (Node.js / Express) // CSP 配置report-only 先行确认无误杀后切换到 enforce。 // 为什么用 nonce 而非 hashnonce 对动态生成的内联脚本更友好 // hash 要求脚本内容固定不适合 SPA 的运行时注入。 const crypto require(crypto); function generateNonce() { return crypto.randomBytes(16).toString(base64); } function buildCSPHeader(nonce, { reportOnly false } {}) { const directives [ default-src self, script-src self nonce-${nonce}, // 禁用 eval阻断 JS 执行链 script-src-attr none, style-src self nonce-${nonce}, img-src self data: https:, connect-src self https://api.example.com, frame-ancestors none, // 上报地址收集违规用于分析 report-uri /api/csp-report, report-to csp-endpoint, ]; const headerName reportOnly ? Content-Security-Policy-Report-Only : Content-Security-Policy; return { [headerName]: directives.join(; ) }; } // 中间件每个请求生成独立 nonce function cspMiddleware(req, res, next) { const nonce generateNonce(); res.locals.nonce nonce; const headers buildCSPHeader(nonce, { reportOnly: process.env.CSP_RO 1, }); Object.entries(headers).forEach(([k, v]) res.setHeader(k, v)); next(); } module.exports { cspMiddleware, buildCSPHeader };// csp-report-handler.ts // CSP 违规上报不是可选项是必须项。 // 没有上报就无法发现误杀与漏网。 import { Router } from express; const router Router(); // 简单的内存聚合生产应写日志或数据库 const violations new Mapstring, number(); router.post(/api/csp-report, (req, res) { const report req.body?.[csp-report]; if (!report) return res.status(400).end(); // 聚合按 violated-directive 加 source-file 去重计数 const key ${report[violated-directive]}|${report[source-file]}; violations.set(key, (violations.get(key) || 0) 1); // 高频违规可能是攻击或误杀触发告警 if (violations.get(key)! 10) { console.warn([CSP] 高频违规, key, violations.get(key)); } res.status(204).end(); }); export { router as cspReportRouter };四、协同防御的代价与边界误杀与绕过三层防御并非银弹各有代价。DOMPurify 的误杀。白名单策略会删除合法但不在名单内的标签。例如用户粘贴带表格的内容但白名单未包含 table 标签内容会被剥离。解决方式是按场景分级配置评论场景用最小白名单富文本编辑器用扩展白名单。代价是配置维护成本上升。DOMPurify 的绕过风险。DOMPurify 依赖浏览器的 HTML 解析器不同浏览器的解析行为存在差异mXSSmutation XSS。例如某些浏览器的标签嵌套纠正会改变 DOM 结构导致净化后的 HTML 在序列化再解析后产生新的恶意节点。DOMPurify 通过二次解析缓解但无法完全消除。生产建议保持 DOMPurify 版本更新关注其安全公告。CSP 的兼容性代价。script-src self 会阻断所有内联脚本这对遗留系统是灾难。大量 jQuery 时代的内联事件处理器会失效迁移成本高。script-src-attr none 更激进直接禁用所有内联事件属性。生产建议新项目直接上 CSP老项目用 report-only 模式收集数据分阶段迁移。Trusted Types 的兼容性。截至 2026 年Firefox 对 TT 的支持仍不完整。在不支持的浏览器上shim 不提供强制保护只能依赖 DOMPurify 加 CSP。因此 TT 是增强层而非基础层不能作为唯一防线。协同失效场景。若 DOMPurify 配置错误如允许 script 标签且 CSP 允许内联脚本三层防御同时失效。这是配置串谋问题。任何一层放宽整体防御水平降至最弱链。生产建议用自动化测试验证配置CI 中集成 CSP 审计。禁用场景。DOMPurify 不适用于 SVG 与 MathML 场景这两个命名空间的标签存在特殊解析行为DOMPurify 默认不处理。若必须渲染用户上传的 SVG需要单独的 SVG 净化配置。CSP 的 unsafe-inline在 Web Components template 内联时可能需要放开但这会大幅削弱防护。五、总结XSS 防御的核心是纵深DOMPurify 净化 HTML 结构Trusted Types 强制 sink 类型CSP 约束脚本执行。三者覆盖不同攻击路径单独使用均有绕过面协同使用才能形成完整防线。落地步骤如下。第一步用 report-only 模式部署 CSP收集现有违规数据。第二步梳理内联脚本迁移到 nonce 或外链。第三步在所有 innerHTML 调用点接入 DOMPurify按场景配置白名单。第四步启用 Trusted Types支持的浏览器用 policy 统一净化入口。第五步CSP 从 report-only 切换为 enforce保留上报通道。第六步在 CI 中集成配置审计防止配置串谋导致防线失效。防护效果以违规上报量为指标。目标enforce 模式上线后正常业务零违规攻击尝试 100% 上报。