1. CSP介绍
Content Security Policy (CSP)是一种让浏览器针对特定网站执行自定义限制的安全策略。
通过 HTTP 响应头返回的 CSP 决定:
- 哪些脚本可以执行
- 哪些图片可以加载
- 哪些接口可以请求
- 哪些 iframe 可以嵌入
- 哪些页面可以把当前页面嵌入进 iframe
- 哪些危险能力可以禁掉
1.1 CSP指令
既然在介绍中说到了允许哪些资源可以加载,那么我们该如何判断我们应该放行哪些,又限制哪些呢?
下面放一个指令集合,方便对CSP中的指令的含义进行一个参考:
| 指令 | 可选值 | 说明 |
|---|---|---|
| default-src | self, none, https域名, https:, data:, blob: | 默认资源加载策略。 |
| base-uri | self, none, https域名 | 限制 base 标签可使用的 URL 来源。 |
| form-action | self, none, https域名 | 限制表单提交目标。 |
| frame-ancestors | self, none, https域名 | 限制哪些页面可以嵌入当前页面。 |
| object-src | none, self, https域名 | 限制 object、embed、applet 等插件类资源。 |
| script-src | self, none, https域名, unsafe-inline, unsafe-eval, strict-dynamic, nonce, sha256, report-sample | 限制 JavaScript 来源和执行方式,如果没有设置的话,会回退到 default-src。 |
| style-src | self, none, https域名, unsafe-inline, nonce, sha256, report-sample | 限制 CSS 来源以及内联样式。 |
| img-src | self, none, https域名, https:, data:, blob: | 限制图片来源。 |
| font-src | self, none, https域名, data: | 限制字体来源。 |
| connect-src | self, none, API域名, WebSocket域名 | 限制 Fetch、XHR、WebSocket 等连接目标。 |
| worker-src | self, none, https域名, blob: | 限制 Worker 脚本来源,如果没有设置的话,浏览器会回退到script-src |
| frame-src | self, none, https域名 | 限制当前页面可以加载哪些 iframe 或 frame。 |
| manifest-src | self, none, https域名 | 限制 Web App Manifest 文件来源。 |
| report-uri | 相对路径, HTTPS上报地址 | CSP 违规报告上报地址。 |
| report-to | csp-endpoint | Reporting API 的端点组名称。 |
| upgrade-insecure-requests | 无值指令,不需要填 | 渐进式接入HTTPS项目中使用,让浏览器把页面里的不安全 http: 请求自动升级成 https: 请求 |
1.2 可选值速查以及含义
| 值 | 类型 | 适用范围 | 说明 |
|---|---|---|---|
| ’self’ | source key | 多数指令 | 允许当前页面同源资源。 |
| ‘none’ | source key | 多数指令 | 禁止所有来源,通常应单独使用。 |
| https域名 | host source | 多数指令 | 允许指定协议、域名和可选端口的来源。 |
| 星号子域名 | host source | 多数指令 | 允许指定域名的子域名。 |
| https: | scheme source | 多数指令 | 允许的HTTPS 来源,需要写成https: 这种source expression格式 |
| data: | scheme source | img-src, font-src | 允许 data URL,需要写成data: 这种source expression格式。 |
| blob: | scheme source | img-src, worker-src | 允许浏览器运行时创建的 Blob URL,需要写成blob: 这种source expression格式 |
| wss域名 | host source | connect-src | 允许指定 WebSocket 地址。比如说wss://domain.example.com |
| ’unsafe-inline’ | key | script-src, style-src | 允许内联脚本或内联样式,不推荐使用。 |
| ‘unsafe-eval’ | key | script-src | 允许 eval 和 new Function 等动态代码执行,不推荐使用。 |
| ‘strict-dynamic’ | key | script-src | 只要一个脚本是通过 Nonce 或 Hash 被验证为合法的,那么这个脚本通过 document.createElement(‘script’) 动态插入到页面里的任何新脚本,都会被浏览器自动信任,无需再查白名单。 为了保证安全性,一旦浏览器检测到 strict-dynamic,它会自动忽略 script-src 里的所有域名白名单(如 https://、‘self’、example.com)。它强迫你使用更安全的 Nonce 或 Hash。 它需要搭配 nonce/hash 后才有意义。 |
| nonce | nonce source | script-src, style-src | 允许带匹配 nonce 属性的内联脚本或样式,注意,要写成nonce-<value>这种形式。简单地说,就是让脚本带上一个每次请求都不同的随机数,符合该随机数的脚本才能运行。在必须使用内联脚本的场景下,它比域名限制更灵活、更安全,但工程成本更高,简单的限制能做好的工作就不要依靠nonce,同时nonce本身也会挑部署方式。 |
| sha256 | hash source | script-src, style-src | 允许内容 hash 匹配的内联脚本或样式,注意,要写成sha256-<base64>这种形式。跟nonce不同,它是让脚本带上自己的内容指纹,也就是通过Hash来确定脚本内容的唯一性,符合哈希的脚本才能被加载。不过当脚本变动的时候就要重新计算哈希,在CI/CD流程中要做维护。 同样,普通域名拦截就能做好的事情,就不要动用更复杂的feature。 |
| ‘unsafe-hashes’ | key | script-src, style-src | 配合 hash 允许特定内联事件处理器或样式属性,不推荐使用。 |
| ‘report-sample’ | key | script-src, style-src | violation报告中附带一小段violation代码样本。 |
scheme source末尾不带//
2. 实践
看到上面那么多指令以及指令的可选值,该怎么使用呢?
先区分使用场景。
| 场景 | 谁返回 HTML | CSP 通常配置在哪里 | 示例 |
|---|---|---|---|
| 静态 SPA + Nginx | Nginx | Nginx response header | Vue / React 打包成 dist,Nginx 托管 |
| 静态 SPA + Caddy | Caddy | Caddy response header | Caddy header 配置 |
| 静态 SPA + CDN | CDN / 边缘平台 | CDN response header / edge rules | Cloudflare、AWS CloudFront、Vercel static headers |
| SSR 应用 | Node SSR 服务或框架服务 | SSR 服务 response header | Next.js、Nuxt、SvelteKit、自写Express SSR |
| 后端模板渲染 | 后端 Web 框架 | 后端中间件或响应头配置 | Spring MVC、Laravel、Django、Rails |
| BFF / API Gateway 返回 HTML | BFF / Gateway | BFF / Gateway response header | Node BFF、Spring、Cloud Gateway |
| Serverless 页面函数 | Serverless function | function response headers | Vercel Functions、Netlify、Functions、Cloudflare Workers |
| 纯 HTML 无法改服务器头 | HTML 文件本身 | meta http-equiv | 只适合部分 CSP 场景,不推荐作为主方案 |
2.1 静态SPA
对于静态SPA,CSP通常放在Nginx、Caddy、CDN、Edge里头,亦或是后端中间件或者响应头的配置。
在CSP介绍中,我们提到让网站通过服务端回传的Header决定这些限制,以普遍前端网站存在的形式而言,几乎都是把打包后的静态文件通过nginx挂载在服务器的某个端口上。
下面是一段配置示例:
location / { add_header Reporting-Endpoints "csp-endpoint=\"https://$host/observability/frontend/v1/security/csp-reports\"" always; add_header Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'report-sample'; style-src 'self' 'unsafe-inline' 'report-sample'; img-src 'self' data: blob: $frontend_shared_resource_origins; font-src 'self' data:; connect-src 'self' wss://domain.website.com $frontend_shared_resource_origins; worker-src 'self' $frontend_worker_src; frame-src 'self' $frontend_shared_resource_origins; manifest-src 'self'; report-uri /observability/frontend/v1/security/csp-reports; report-to csp-endpoint" always; ..... }对里面一些值的解释:
| 字段 | 类型 | 说明 |
|---|---|---|
| add_header | Nginx 指令 | 给响应添加 HTTP Header。 |
| Content-Security-Policy-Report-Only | HTTP 响应头 | CSP 的只报告不拦截模式。违反策略时浏览器会上报,但不会阻止资源加载。 |
| Content-Security-Policy | HTTP 响应头 | 与上面的二选一,正式进入拦截模式,违反策略时也会上报,但会阻止资源加载。 |
| always | Nginx 参数 | 表示尽可能在各类响应状态码中都添加该 Header。 |
纯静态SPA站点 无法使用 nonce(因为每次请求必须不同),如果必须允许内联脚本,通常改用 Hash(但维护成本较高)或重构为外联脚本。
一般来说,都是重构为外联脚本,本身仅允许’self’使用,比维护Hash成本低得多。
2.2 SSR
SSR 大概是这种情况:
app.get('*', async (req, res) => { // 仅为示例,可以将上面提到的那一行直接复制过来。 res.setHeader( 'Content-Security-Policy', ".....", )
const html = await render(req.url) res.send(html) })在 SSR 里把 CSP 放到应用层有一个重要优势:可以为每个请求生成动态 nonce。
例如:
app.get('*', async (req, res) => { // 对于nonce的值,一般是生成一段不可预测的随机字节,常见取 16 或 32 字节,然后把这段字节编码放进 Header/HTML 的字符串,最常见是 base64。 const nonce = crypto.randomBytes(16).toString('base64')
res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`, )
const html = await render(req.url, { nonce }) res.send(html) })对应 HTML:
<script nonce="..."></script> <style nonce="..."></style>这类动态 nonce 很难只靠 Nginx 静态配置优雅完成,因为它需要Header里的nonce和HTML里的nonce一致。
2.3 CDN / Edge
只说一个典型:Cloudflare。
别的根据供应商设置吧,大差不差的(配置的差异主要还是看供应商在哪里提供设置header或者rules,设置的内容是不变的)
2.3.1 Cloudflare Pages: _headers
把 headers 放在静态资源目录或最终构建产物目录中。Cloudflare Pages 官方文档说明_headers 会被解析并应用到静态资源响应。
但如果响应来自 Pages Functions / SSR,则需要在函数代码里设置 Header。
/* Content-Security-Policy-Report-Only: default-src 'self'; object-src 'none'; report-uri https://example.com/csp-reports; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"CSP的写法本身对展示并不友好,注意Content-Security-Policy-Report-Only或者Content-Security-Policy需要在一行内写完,无论多长的规则都需要在一行内解决。
与其在文章里展示一整条很长的CSP规则实际黏贴后,发现因为文本编辑器的原因导致意外分行,不如先看看短规则,这样方便理解_headers的格式规则,再根据项目实际策略自行拼接。
可以自行查看Cloudflare Pages Headers文档、CSP示例。
2.3.2 Cloudflare Transform Rules
这种方式其实就是已有origin,但是origin机器管理比较麻烦的时候使用。
| 项 | 值 |
|---|---|
| 入口 | Cloudflare Dashboard -> Rules -> Transform Rules -> Response Header Modification |
| Rule filter | 例如 http.host eq “example.com” |
| Operation | Set static |
| Header name | Content-Security-Policy-Report-Only 或 Content-Security-Policy |
| Header value | 完整 CSP 字符串,也就是之前给出的一大串 |
| 额外 Header | 如果用 report-to,还要加 Reporting-Endpoints |
Cloudflare Response Header Transform Rules 支持设置、添加、删除响应头,也可以通过 dashboard、API、Terraform 创建。
Transform Rules 要求域名 DNS 记录走 Cloudflare 代理。
可以自行查看Cloudflare Response Header Transform Rules、Transform Rules 说明。
注意:
如果
origin已经返回CSP,那就不要在CDN上再加一条不同 CSP,这样会导致资源必须满足所有策略才能加载,最终效果相当于取交集,可能使原本宽松的单条策略变得出乎意料地严格或产生冲突。
通常用 Set static 覆盖,或者先移除旧 Header 再统一设置。
建议遵循single source of truth,不要套娃(
2.3.3 Cloudflare Workers / Edge Function
适合 SSR、边缘渲染、需要动态 nonce 的场景。
export default { async fetch(request: Request): Promise<Response> { const response = await fetch(request) const next = new Response(response.body, response)
next.headers.set( 'Content-Security-Policy-Report-Only', ".....", )
next.headers.set( 'Reporting-Endpoints',7 collapsed lines
// 下面的按照自己项目来哈,只是给出这么个例子,要改成你项目本身的report渠道 'csp-endpoint="...."', )
return next }, }如果需要 nonce,应在这里生成,并且同时写入 Header 和 HTML:
注意:
Workers运行时为V8 isolate,应该使用Web Crypto API
// Web Crypto API与Node的Crypto用法不一样,不能使用crypto.randomBytes(16).toString('base64') const array = new Uint8Array(16); crypto.getRandomValues(array); const nonce = btoa(String.fromCharCode(...array));
const policy = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}'`, `style-src 'self' 'nonce-${nonce}'`, "object-src 'none'", "base-uri 'self'", ].join('; ')静态 Header 配置适合固定 CSP;动态 nonce 必须由能改 HTML 的 SSR / Edge / Worker 代码完成。
2.4 HTML Meta
<meta http-equiv="Content-Security-Policy" content="......" >一般会设置在<head>标签里面,越靠前越好。
另外<meta http-equiv="Content-Security-Policy"> 不能下发 Content-Security-Policy-Report-Only,且不支持完整 CSP 能力,这就是上面所说的功能不全的问题。
| 能力 | 是否支持 |
|---|---|
| Content-Security-Policy-Report-Only | 不支持 |
| report-uri | 不支持 |
| report-to | 不支持 |
| frame-ancestors | 不支持 |
| sandbox | 不支持 |
| 动态 nonce | 不适合 |
report-uri已弃用,但因为report-to还没有广泛支持,HTTP Header 场景下通常会同时配置report-uri和report-to做兼容。
HTML Meta不适合承担CSP上报能力,这也是它不适合作为主方案的原因之一。
| 原因 | |
|---|---|
| 已被标记为 deprecated | MDN 明确标注 report-uri 已弃用,不再推荐,并提示未来可能停止工作。 |
| 被 report-to 覆盖 | CSP Level 3 和 MDN 都说明:如果浏览器支持并存在 report-to,report-uri 会被忽略。 |
| 交付结果被浏览器忽略 | CSP 规范里 report-uri 的发送是一个 no-cors、keepalive 的 POST,并且 fetch结果会被忽略。也就是说页面侧无法确认上报成功。 |
| 单 violation 单请求,不可扩展 | CSP Level 3 规范直接评价 report-uri 的行为:每个 violation 发一个请求,不具备可扩展性。 |
| 没有强交付保证 | Web reporting 这类浏览器报告机制本质是 best-effort,不适合作为可靠通信通道。 |
| 报告内容脱敏 | 例如跨源 blocked-uri 可能只保留 scheme、host、port;不是完整 URL。 |
参考:
CSP Level 3:report-uri 被 report-to 替代#6.5.1 report-uri
CSP Level 3:report-uri 的请求结果会被忽略#5.5 Report a violation
3. 一些你可能用得上的命令
如果没有rg工具,建议自行配置,或使用别的工具,文中仅以rg作为示例。
3.1 扫描源码中是否存在影响CSP切换的高风险模式
rg -n "https?://(unpkg\.com|cdn\.jsdelivr\.net|cdnjs\.cloudflare\.com|esm\.sh|cdn\.skypack\.dev| ga\.jspm\.io)\b|new\s+Function\b|\beval\s*\(|setTimeout\s*\(\s*['\"\`]|setInterval\s*\(\s*['\"\`]" src| 风险类型 | 匹配内容 | CSP 影响 |
|---|---|---|
| 运行时远程 CDN | unpkg、jsdelivr、cdnjs、esm.sh、skypack、jspm | 可能迫使 script-src、style-src、img-src 放开外部来源。 |
| 动态代码执行 | new Function | 通常需要 script-src ‘unsafe-eval’,风险较高。 |
| 动态代码执行 | eval(…) | 通常需要 script-src ‘unsafe-eval’,风险较高。 |
| 字符串定时器 | setTimeout(”…”) | 类似动态执行,容易触发 CSP 风险。 |
| 字符串定时器 | setInterval(”…”) | 类似动态执行,容易触发 CSP 风险。 |
| 主要是查看src目录下是否有这种高危代码。 |
unpkg、jsdelivr、cdnjs、esm.sh、skypack、jspm是因为我引用的markdown组件中存在自挂载cdn引用这些外链,解决方案是本地化+懒加载。
对于setTimeout以及setInterval,主要阻止的是第一个参数为字符串字面量的场景,而非正常的代码:
| 写法 | 是否风险 | 原因 |
|---|---|---|
| setTimeout(fn, delay) | 否 | 传入的是函数引用或 callback,不需要字符串求值。 |
| setTimeout(() => {}, delay) | 否 | 传入的是函数,不是动态代码字符串。 |
| setInterval(fn, delay) | 否 | 传入的是函数引用。 |
| setTimeout(“doSomething()”, delay) | 是 | 字符串会被当作代码执行,类似 eval。 |
| setInterval(“doSomething()”, delay) | 是 | 同上。 |
3.2 扫描构建产物
pnpm buildrg -n "https?://(unpkg\.com|cdn\.jsdelivr\.net|cdnjs\.cloudflare\.com|esm\.sh| cdn\.skypack\.dev|ga\.jspm\.io)\b|new\s+Function\b|\beval\s*\(|setTimeout\s*\(\s*['\"\`]| setInterval\s*\(\s*['\"\`]" dist跟上面一样,只不过是在build之后将扫描目录从src目录换成了dist目录
3.3 分开扫描远程 CDN
如果只是想扫描CDN依赖:
rg -n "https?://(unpkg\.com|cdn\.jsdelivr\.net|cdnjs\.cloudflare\.com|esm\.sh|cdn\.skypack\.dev| ga\.jspm\.io)\b" src根据自己实际需求替换掉里面的CDN链接,如果你使用的CDN固定,可以干脆在nginx配置中指定对某个CDN域名或者对应的CDN资源进行放开。
3.4 分开扫描动态执行
rg -n "new\s+Function\b|\beval\s*\(|setTimeout\s*\(\s*['\"\`]|setInterval\s*\(\s*['\"\`]" src同样,有扫描dist产物需求的自行将src替换成dist即可。
4. 总结
| 配置位置 | 适合场景 | 优点 | 局限 |
|---|---|---|---|
| Nginx / Caddy | 静态 SPA、统一入口代理 | 简单、集中、和应用代码解耦 | 不适合动态 nonce,修改需要部署配置 |
| CDN / Edge | 静态站点、全球分发 | 靠近用户、统一管理 | 平台差异大,复杂策略可维护性一般,结合使用平台做评估吧。 |
| SSR / 后端应用 | SSR、模板渲染、需要动态 nonce | 可按请求生成 nonce,结合用户、路由、环境做策略 | 安全策略进入应用代码,需要注意错配的问题。 |
| 后端框架中间件 | 后端渲染页面或 BFF | 可统一管理 Header,适合服务端应用,不过一般来说不会选择在后端里面做。 | 对纯静态资源托管不一定覆盖完整 |
| HTML meta | 没法改响应头时的兜底 | 直接配就行,最快落地。 | 能力不完整,不适合作为主方案 |
每个人的项目大相径庭,正常来说应该是先开Content-Security-Policy-Report-Only,在不同页面中通过CSP violation report(可以考虑自动化巡回页面检测),将所有的外链/script/图片/websocket/sse之类的连接给找出来,限制其生效范围,这样可以显著收缩非信任源脚本执行和资源加载的空间,从而降低 XSS 的利用面。
但也切记,CSP本身只是缩小打击面,并不是一劳永逸的做法。
可以通过 Google CSP Evaluator 自动评估安全性并指出风险,不过仅作参考,结合自己项目实际判断。
5. Trusted Types:限制 DOM XSS 注入点。
这节主要是拓展,所以放到了总结下面。
CSP主要限制资源加载与执行边界,例如脚本来源、内联脚本、动态执行、Worker、连接目标等;但默认情况下不负责把所有 Runtime 里的字符串 DOM 注入点也一同做出限制。
比如说:
element.innerHTML = userInput对于 DOM XSS 攻击而言,可以通过Trusted Types来限制其范围。
于是便有了Trusted Types,其通常通过 CSP 指令启用,但它本身也包含浏览器提供的 Trusted Types API 与 policy 机制。
Trusted Types的作用是:禁止把普通字符串直接写入危险 DOM API,要求必须传入由受信任 policy 创建的 TrustedHTML、TrustedScript 或 TrustedScriptURL。
典型危险 sink 包括:
| 类型 | 示例 |
|---|---|
| HTML 注入 | innerHTML、outerHTML、insertAdjacentHTML、document.write、 document.writeln、DOMParser.parseFromString、Range.createContextualFragment、iframe.srcdoc、 ShadowRoot.innerHTML、script.text、script.textContent、Worker() / SharedWorker() |
| 脚本相关 | script.src、eval、new Function |
| 字符串动态执行 | 字符串形式的 setTimeout、setInterval |
相关 CSP 指令
| 指令 | 作用 | 示例 |
|---|---|---|
| require-trusted-types-for | 强制指定类型的 sink 必须使用 Trusted Types | require-trusted-types-for ‘script’ |
| trusted-types | 限制页面允许创建哪些 Trusted Types policy | trusted-types default dompurify |
基础写法:
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types app-html更严格的写法:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html这里面的app-html,指的是自定义的policy名称,名称可以有多个,代表了不同的规则,用空格隔开。接下来就只允许叫做app-html的policy在项目中被声明。
如下,这是一个最小应用示例。
import DOMPurify from 'dompurify'
const policy = trustedTypes.createPolicy('app-html', { createHTML(input) { // 这里可以直接返回input,如果直接返回,开启Trusted Types后: // 如果你写的是 element.innerHTML = userInput,会报 violation。 // 如果你写的是 element.innerHTML = policy.createHTML(userInput),而 policy 被 CSP 允许创建,那么通常不会因为这个赋值本身报 violation。 // 但如果 createHTML() 只是 return input,那只是“浏览器接受了这个 trusted 值”,不代表内容安全。真正让其安全的做法就是类似于下面这样,做清洗。 // 当前加上了DOMPurify.sanitize做清洗,这部分的代码就会被无效化。 return DOMPurify.sanitize(input) },})8 collapsed lines
const userInput = '<img src=x onerror=alert(1)>Hello'
// 错误示例I// element.innerHTML = userInput
// 正确示例.element.innerHTML = policy.createHTML(userInput)| 注意项 | 说明 |
|---|---|
| 浏览器支持 | 最新版主流浏览器已可用,但旧浏览器和企业内老环境仍需按真实 UA 验证 |
| sanitizer | Trusted Types 仅强制走 policy,并不自动清洗内容。真正的清洗逻辑需要使用 DOMPurify等 sanitizer。 |
| 先Report-Only | 直接 enforce 使用sanitizer做清洗,可能部分依赖库或富文本、Markdown、编辑器等功能会被限制,建议先观察再调整。 |
| policy范围 | 不建议长期使用 trusted-types *,用上了就做细一些,否则没什么意义。 |
| default policy | default policy 会吞掉很多问题,并不建议使用,建议直接创建显式policy。 |
应用的开放程度会影响 CSP 与 Trusted Types 的优先级,但不会改变其技术价值。
B 端系统通常风险暴露面更小,但并不意味着可以忽略 DOM XSS、第三方依赖行为或误配置带来的问题。只不过优先级可以往后挪,等产品上线后边测边做。
5.1 注入时机
很多 DOM XSS 并不是在接收到用户输入时发生,而是在运行时把字符串写入危险 DOM API 时发生。
例如:
element.innerHTML = userInput这里真正的风险点不是 userInput 变量本身,而是它进入了 innerHTML 这个危险 sink。
同理,eval、new Function、script.src、Worker(blob:...) 等场景,本质上也都是“字符串在某个运行时刻被解释为可执行内容”。
这也是为什么 Trusted Types 要限制的不是用户输入本身,而是普通字符串进入危险 sink 的方式。
5.2 Policy的创建
开启 Trusted Types 后,浏览器会告诉开发者危险sink不能直接传递string,必须传递TrustedHTML / TrustedScriptURL。
// 错误:不能直接传普通 stringelement.innerHTML = userInput对于创建TrustedHTML / TrustedScriptURL,Trusted Types有一套固定的流程,首先我们要创建policy。
之前提到的app-html这个policy名仅为演示,现在我们来一个新的policy,就叫做 contract-frontend-html。
但是,policy本身并不做sanitize操作,因为不同的HTML / Script 可能需要的sanitize行为不一样,这里主要是校验是否已经经过sanitize。
const clean = DOMPurify.sanitize(dirty) // 这里做清洗const input = asSanitizedHtmlInput(clean) // 校验createTrustedHtmlFromSanitized(input) // 创建 TrustedHTML// policy的名字,可以自定义,这里仅为示范const CONTRACT_FRONTEND_HTML_POLICY = 'contract-frontend-html'
export const TRUSTED_TYPES_POLICY_NAME = CONTRACT_FRONTEND_HTML_POLICY
// 这个类型代表装载的value已经是经过sanitized的HTML。export type SanitizedHtmlInput = { readonly kind: 'sanitized-html' readonly value: string}
// 统一Policy的内容,一个是HTML,一个是ScriptURL189 collapsed lines
type TrustedTypesPolicyShape = { createHTML(input: string): string createScriptURL?(input: string): string}
// 规范TrustedTypesPolicyFactory的类型type TrustedTypesPolicyFactory = { createPolicy(name: string, rules: TrustedTypesPolicyShape): TrustedTypesPolicyShape}
let trustedTypesPolicy: TrustedTypesPolicyShape | null | undefinedlet trustedTypesPolicyFactory: TrustedTypesPolicyFactory | null | undefined
// 调用方不能直接传字符串给 HTML policy,而要先wrap。export const asSanitizedHtmlInput = (value: string): SanitizedHtmlInput => ({ kind: 'sanitized-html', value,})
// 获取从factory中得到的TrustedHTMLexport const createTrustedHtmlFromSanitized = (input: SanitizedHtmlInput | unknown) => { // 检查input const payload = assertSanitizedHtmlInput(input) // 返回TrustedHTML,sanitize在之前已经完成了,这个相当于盖章。(虽然什么都没做,但还是辛苦它了) // 这里也有降级处理,如果浏览器不支持Trusted Types,返回原始string return getContractFrontendHtmlPolicy()?.createHTML(payload.value) ?? payload.value}
// 检查:// kind === 'sanitized-html'// typeof value === 'string'// 不符合要求就抛错:createTrustedHtmlFromSanitized requires SanitizedHtmlInputfunction assertSanitizedHtmlInput(value: unknown): SanitizedHtmlInput { if ( typeof value !== 'object' || value === null || !('kind' in value) || !('value' in value) || (value as { kind?: string }).kind !== 'sanitized-html' || typeof (value as { value?: unknown }).value !== 'string' ) { throw new TypeError('createTrustedHtmlFromSanitized requires SanitizedHtmlInput') }
return value as SanitizedHtmlInput}
// 复用创建的html policyexport const getContractFrontendHtmlPolicy = () => { const factory = getTrustedTypesFactory()
if (trustedTypesPolicy !== undefined) { if (trustedTypesPolicyFactory === factory) { return trustedTypesPolicy } }
if (!factory) { trustedTypesPolicyFactory = null trustedTypesPolicy = null return trustedTypesPolicy }
trustedTypesPolicyFactory = factory trustedTypesPolicy = factory.createPolicy(CONTRACT_FRONTEND_HTML_POLICY, { createHTML(input) { // 考虑到不同场景需要不同的sanitizer profile,所以不在这里做统一的input清洗处理。 return input }, createScriptURL(input) { // 同上,我们的sanitizer步骤不在这里进行。 return input }, })
return trustedTypesPolicy}
// 获取Trusted Types实例const getTrustedTypesFactory = (): TrustedTypesPolicyFactory | null => { if (typeof window === 'undefined' || typeof window.trustedTypes === 'undefined') { return null }
return window.trustedTypes as TrustedTypesPolicyFactory}
// 给类似于OpenReplay的类似于worker blob URL使用。// 这类第三方库可能会涉及到这样的操作:new Worker('blob:https://...')// 将blob:URL像上面那样,变成TrustedScriptURL,避免触发violation,这并非跳过检查,而是不让本身受信内容触发csp violation report。export const createTrustedScriptUrl = (input: string) => getContractFrontendHtmlPolicy()?.createScriptURL?.(input) ?? input
// 需要颁发受信的ScriptURL规则,这里比较粗,意思意思。const needsTrustedWorkerScriptUrl = (value: unknown): value is string => typeof value === 'string' && value.startsWith('blob:')
// 接收 scriptURL 和 options,new出来一个workertype WorkerConstructor = { new (scriptURL: string | URL, options?: WorkerOptions): Worker prototype: Worker}
// 同上,这个worker可以同源跨标签生效,共享context。type SharedWorkerConstructor = { new (scriptURL: string | URL, options?: string | WorkerOptions): SharedWorker prototype: SharedWorker}
// 传进来一个原生的构造器,返回一个包装后的构造器。const wrapWorkerConstructor = <TConstructor extends WorkerConstructor | SharedWorkerConstructor>( NativeConstructor: TConstructor,) => { const TrustedWorkerConstructor = function WorkerConstructor( this: Worker | SharedWorker, scriptURL: string | URL, options?: string | WorkerOptions, ) { // 处理blob: 开头的scriptURL,但不用改第三方的代码,让其变成Trusted scriptURL。 const nextScriptURL = needsTrustedWorkerScriptUrl(scriptURL) ? createTrustedScriptUrl(scriptURL) : scriptURL // 可以粗暴理解成return new NativeConstructor(nextScriptURL, options) // 为什么要这么写后面会有解释,包括prototype的赋值。 return Reflect.construct( NativeConstructor, [nextScriptURL, options], new.target ?? NativeConstructor, ) } as unknown as TConstructor // TrustedWorkerConstructor.prototype这个对象被创建出来之后是个空值(默认就是如此) // Reflect.construct(NativeConstructor, args, new.target)创建的实例_proto_会引用TrustedWorkerConstructor.prototype,而不是NativeWorker.prototype。 // 而TrustedWorkerConstructor.prototype要手动赋值一下,因为创建的function默认的prototype是一个默认的几乎为空对象的值,得跟对应的NativeWorker.prototype对齐。 TrustedWorkerConstructor.prototype = NativeConstructor.prototype // 这样最终的TrustedWorker就只是将nextScriptURL代理了一下,其它的行为完全对齐原来的NativeWorker,不影响原有的任何行为,从而达到不修改第三方代码但完成了自己的目的。 return TrustedWorkerConstructor}// 在globalThis中挂载两个修改过的worker,返回一个可以恢复为原生worker的函数。// 这样初始化失败时,测试中需要清理状态时,可以变回原生Worker,既然做了就要提供undo的fn。export function installTrustedTypesWorkerConstructors(): (() => void) | null { if (typeof window === 'undefined' || typeof window.trustedTypes === 'undefined') { return null }
const NativeWorker = globalThis.Worker const NativeSharedWorker = globalThis.SharedWorker let installedWorker = false let installedSharedWorker = false
if (typeof NativeWorker === 'function') { Object.defineProperty(globalThis, 'Worker', { configurable: true, writable: true, value: wrapWorkerConstructor(NativeWorker), }) installedWorker = true }
if (typeof NativeSharedWorker === 'function') { Object.defineProperty(globalThis, 'SharedWorker', { configurable: true, writable: true, value: wrapWorkerConstructor(NativeSharedWorker), }) installedSharedWorker = true }
if (!installedWorker && !installedSharedWorker) { return null }
return () => { if (installedWorker) { Object.defineProperty(globalThis, 'Worker', { configurable: true, writable: true, value: NativeWorker, }) }
if (installedSharedWorker) { Object.defineProperty(globalThis, 'SharedWorker', { configurable: true, writable: true, value: NativeSharedWorker, }) } }}简单解释一下为什么要写为以下形式:
return Reflect.construct( NativeConstructor, [nextScriptURL, options], new.target ?? NativeConstructor,)如果按照比较符合直觉的写法,应该是这样的:
const TrustedWorkerConstructor = function WorkerConstructor( this: Worker | SharedWorker, scriptURL: string | URL, options?: string | WorkerOptions, ) { const nextScriptURL = needsTrustedWorkerScriptUrl(scriptURL) ? createTrustedScriptUrl(scriptURL) : scriptURL return new NativeConstructor(nextScriptURL, options) } as unknown as TConstructor但这样的话,会有一个很明显的问题,对于构造函数来说,其创建出来实例的__proto__会被赋值为构造函数的prototype,这样原型链的继承就出现了问题,按照我们预想的情况,得到的实例指向的肯定得是TrustedWorkerConstructor.prototype而非NativeConstructor.prototype。
其实这个问题跟以前经常提到的this的指向问题是一个道理,这是JavaScript为了维持其灵活性做出的一些特性,所以我们需要使用Reflect.construct去改变这个__proto__的指向。
return Reflect.construct( NativeConstructor, [nextScriptURL, options], new.target ?? NativeConstructor,)按照上面这个写法,新实例的 __proto__ 才会正确指向 new.target.prototype。
除此之外,类似于在你不想明确写出子类却又想调用抽象类并实例化的时候,也可以用到这个办法,一般会用于测试之类的场景,这样就不用批量去建不同的子类了。
5.3 针对Markdown编辑器的最小有效sanitize
首先要关闭 Raw HTML 功能,这是最快也是最应该做的限制,除非你有这方面的刚性需求,否则我不太认为一个网站会宽容到允许用户自行输入 HTML 代码。
然后我们针对 markdown 的 html 表达以及 markdown 内置的 mermaid 做简单的 sanitize 。
这里主要是:渲染后,进入DOM前。
import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'import { getContractFrontendHtmlPolicy } from '@/modules/shared/application/security/trusted-types'// Markdown HTML 清洗const MARKDOWN_HTML_CONFIG: DOMPurifyConfig = { USE_PROFILES: { html: true }, FORBID_TAGS: ['script', 'style', 'iframe'], FORBID_ATTR: ['srcdoc'], ALLOW_DATA_ATTR: true, RETURN_TRUSTED_TYPE: false,}// Mermaid SVG 清洗const MARKDOWN_SVG_CONFIG: DOMPurifyConfig = {31 collapsed lines
USE_PROFILES: { svg: true, svgFilters: true }, // 禁用<foreignObject>、<script>、<style> FORBID_TAGS: ['foreignObject', 'script', 'style'], // 禁用srcdoc属性 FORBID_ATTR: ['srcdoc'], // 允许data-*的属性 ALLOW_DATA_ATTR: true, RETURN_TRUSTED_TYPE: false,}
function sanitizeHtmlWithConfig(input: string, config: DOMPurifyConfig) { const trustedTypesPolicy = getContractFrontendHtmlPolicy()
return DOMPurify.sanitize(input, { ...config, ...(trustedTypesPolicy ? { TRUSTED_TYPES_POLICY: trustedTypesPolicy as unknown as DOMPurifyConfig['TRUSTED_TYPES_POLICY'], } : {}), })}
export function sanitizeMarkdownHtml(html: string) { return sanitizeHtmlWithConfig(html, MARKDOWN_HTML_CONFIG)}
export async function sanitizeMarkdownMermaid(svg: string) { return sanitizeHtmlWithConfig(svg, MARKDOWN_SVG_CONFIG)}如果还有什么敏感的资源,可以根据自行需求过滤,这里只是展示一个最小做法。
在不明显破坏现有编辑器/预览能力的前提下,先去掉危险&直接的执行型内容。
对于 mermaid 而言,官方一直都有积极测试其在 CSP Trusted Types 下的表现,也尽量在做测试,在未显式传递配置的情况下,其初始化时其 securityLevel 已经在 strict 的层面。
但最好还是显式传递一下,也方便后面换更高级别的防护。
function createStrictMermaidConfig(config) { return { ...config, securityLevel: 'strict', } }5.4 整体配置
对外提供开启了securityLevel、关闭了html功能以及设置了安全配置的preview以及runtime组件:
import { defineComponent, h, type Component } from 'vue'import { sanitizeMarkdownHtml, sanitizeMarkdownMermaid } from './markdown-security'
// 运行时promise缓存,防止同一套runtime被重复初始化,多个editor组件同时出现时只初始化一次。let mdPreviewRuntimePromise: Promise<void> | null = nulllet mdEditorRuntimePromise: Promise<void> | null = null
function disableMarkdownRawHtml(md: { options: { html?: boolean } }) { // 关闭markdown的html md.options.html = false}127 collapsed lines
// mermaid 配置显式传递 strict,不依赖默认值。function createStrictMermaidConfig<TConfig extends Record<string, unknown>>(config: TConfig) { return { ...config, securityLevel: 'strict', }}
// 安全配置const markdownSecurityConfig = { markdownItConfig: disableMarkdownRawHtml, mermaidConfig: createStrictMermaidConfig,}
// 这个主要是做兼容ESM/CJS默认导出差异,有些依赖导入后是module.default,有些依赖直接就是instance,统一flat成instance,避免到处判断。function resolveModuleDefault<T>(module: T | { default: T }): T { if (typeof module === 'object' && module !== null && 'default' in module) { return module.default }
return module as T}
// 动态导入preview所需依赖(包含样式)async function ensureMdPreviewRuntime() { if (!mdPreviewRuntimePromise) { mdPreviewRuntimePromise = (async () => { const [{ config }, highlightModule, mermaidModule, katexModule] = await Promise.all([ import('md-editor-v3'), import('highlight.js'), import('mermaid'), import('katex'), import('highlight.js/styles/atom-one-light.css'), import('katex/dist/katex.min.css'), ])
config({ ...markdownSecurityConfig, editorExtensions: { highlight: { instance: resolveModuleDefault(highlightModule), }, mermaid: { instance: resolveModuleDefault(mermaidModule), }, katex: { instance: resolveModuleDefault(katexModule), }, }, }) })() }
return mdPreviewRuntimePromise}
// 动态导入runtime所需依赖,对比preview,多了格式化、全屏之类的功能。async function ensureMdEditorRuntime() { if (!mdEditorRuntimePromise) { mdEditorRuntimePromise = (async () => { await ensureMdPreviewRuntime()
const [{ config }, prettierModule, parserMarkdownModule, cropperModule, screenfullModule] = await Promise.all([ import('md-editor-v3'), import('prettier/standalone'), import('prettier/plugins/markdown'), import('cropperjs'), import('screenfull'), import('cropperjs/dist/cropper.css'), ])
config({ ...markdownSecurityConfig, editorExtensions: { prettier: { prettierInstance: resolveModuleDefault(prettierModule), parserMarkdownInstance: resolveModuleDefault(parserMarkdownModule), }, cropper: { instance: resolveModuleDefault(cropperModule), }, screenfull: { instance: resolveModuleDefault(screenfullModule), }, }, }) })() }
return mdEditorRuntimePromise}
// 创建一个预填参数的mdeditor组件function createWorkOrderMarkdownComponent(component: Component, name: string) { return defineComponent({ name, inheritAttrs: false, setup(_, { attrs, slots }) { return () => h( component, { ...attrs, noEcharts: true, sanitize: sanitizeMarkdownHtml, sanitizeMermaid: sanitizeMarkdownMermaid, }, slots, ) }, })}
export async function loadMdEditor() { await Promise.all([ensureMdEditorRuntime(), import('md-editor-v3/lib/style.css')]) const { default: MdEditor } = await import('md-editor-v3/lib/es/MdEditor.mjs') return createWorkOrderMarkdownComponent(MdEditor, 'WorkOrderMdEditor')}
export async function loadMdPreview() { await Promise.all([ensureMdPreviewRuntime(), import('md-editor-v3/lib/preview.css')]) const { default: MdPreview } = await import('md-editor-v3/lib/es/MdPreview.mjs') return createWorkOrderMarkdownComponent(MdPreview, 'WorkOrderMdPreview')}调用 markdown 组件的封装以及调用方式:
import { sanitizeMarkdownHtml, sanitizeMarkdownMermaid } from './markdown-security'
export async function loadMdEditor() { await Promise.all([ensureMdEditorRuntime(), import('md-editor-v3/lib/style.css')]) const { default: MdEditor } = await import('md-editor-v3/lib/es/MdEditor.mjs') return MdEditor}const AsyncMdEditor = defineAsyncComponent(loadMdEditor)组件调用展示:
<Suspense> <component :is="AsyncMdEditor" v-model="content" :language="'zh-CN'" style="width: 100%" :preview="false" @on-upload-img="onUploadImg" /> ...</Suspense>6. 观测
我项目主要是使用Signoz进行CSP Report观测,这里仅作为补充,相当于附录。
6.1 观测 collector
import type { CspViolationPayload, ObservabilityConfig } from '../types'import { getCurrentTraceContext } from '../otel/tracer'import { getSessionId, getSessionUrl, trackEvent } from '../replay/openreplay'import { sendCspViolationReport } from '../transports/security-report-transport'import { createCspViolationAntiStorm, type CspAntiStormMetadata } from './csp-violation-anti-storm'
// 防止重复初始化let isInitialized = false// 保存传入的观测配置,在 violation 事件处理时使用let config: ObservabilityConfig | null = null
// 去重时间窗口:60 秒内相同指纹的违规只上报少量详情,其余聚合为摘要210 collapsed lines
const CSP_DEDUPE_WINDOW_MS = 60_000// 每个窗口期内允许上报的最多详细报告数(超过数目的将聚合)const CSP_MAX_DETAILED_REPORTS_PER_WINDOW = 3// 最多记录的指纹数量(防止内存无限增长)const CSP_MAX_FINGERPRINTS = 100
// 创建反风暴实例,用于去重、限流、聚合const antiStorm = createCspViolationAntiStorm({ windowMs: CSP_DEDUPE_WINDOW_MS, maxDetailedReportsPerWindow: CSP_MAX_DETAILED_REPORTS_PER_WINDOW, maxFingerprints: CSP_MAX_FINGERPRINTS,})
// 定时器handle,用于周期性刷新聚合的摘要报告let summaryFlushTimer: number | null = null
/** 清除定时器,通常在页面卸载或手动 flush 时调用 */function clearSummaryFlushTimer() { if (!summaryFlushTimer || typeof window === 'undefined') { return } window.clearTimeout(summaryFlushTimer) summaryFlushTimer = null}
/** * 发送一条完整的 CSP 违规报告(详细或聚合摘要)。 * 将防抖节流的Metadata(如指纹、计数)合并到 payload 中一并上报。 */function sendPayload(payload: CspViolationPayload, metadata: CspAntiStormMetadata) { if (!config) { return } void sendCspViolationReport({ ...payload, ...metadata }, config)}/** * 强制将所有当前窗口内累积的聚合摘要立即发送。 * @param includeOpenWindow - 是否包含尚未结束的窗口(页面卸载时应为 true) */function flushCspDuplicateSummaries(includeOpenWindow = false) { if (!config || typeof window === 'undefined') { return } const observedAt = Date.now() const summaries = antiStorm.flush(observedAt, { includeOpenWindow }) for (const summary of summaries) { // 摘要中已经包含 exemplar(代表该指纹的典型违规)和元数据 const { exemplar, ...metadata } = summary void sendCspViolationReport( { // 从典型违规中获取大部分字段 ...exemplar, channel: 'securitypolicyviolation', observedAt, route: exemplar.route ?? window.location.pathname, release: config.serviceRelease, serviceName: config.serviceName, serviceVersion: config.serviceVersion, environment: config.environment, userAgent: navigator.userAgent, language: navigator.language, // 附加摘要特有的元数据(如次数、首个/末次时间等) ...metadata, }, config, ) }}/** 调度一个定时器,在窗口时间后自动发送聚合摘要 */function scheduleSummaryFlush() { if (summaryFlushTimer || typeof window === 'undefined') { return } summaryFlushTimer = window.setTimeout(() => { summaryFlushTimer = null flushCspDuplicateSummaries() }, CSP_DEDUPE_WINDOW_MS)}/** 页面卸载时立即发送所有未处理的聚合摘要,并取消定时器 */function flushOpenSummaries() { clearSummaryFlushTimer() flushCspDuplicateSummaries(true)}
/** * CSP 违规事件处理函数。 * 当浏览器触发 `securitypolicyviolation` 事件时,收集所有相关字段, * 构造上报 payload,分别通过: * 1. 反风暴模块判断发送详情还是聚合摘要 * 2. 会话回放/埋点工具(trackEvent) * 进行记录。 */const handleViolation = (event: Event) => { if (!config) { return }
const violation = event as SecurityPolicyViolationEvent
const traceContext = getCurrentTraceContext() const sessionId = getSessionId() const sessionUrl = getSessionUrl() const observedAt = Date.now()
// 构造统一上报的数据结构 const payload: CspViolationPayload = { // 标记事件渠道,便于后端识别 channel: 'securitypolicyviolation' as const, // CSP 中被阻止的资源 URL blockedUri: violation.blockedURI || undefined, // 违规代码所在列号(如有) columnNumber: violation.columnNumber || undefined, // 处置方式:enforce(拦截) 或 report(仅报告) disposition: violation.disposition, // 触发违规的页面 URL documentUri: violation.documentURI || window.location.href, // 实际生效的 CSP 指令 effectiveDirective: violation.effectiveDirective, // 违规代码行号(如有) lineNumber: violation.lineNumber || undefined, // 本次请求生效的完整 CSP 策略字符串 originalPolicy: violation.originalPolicy, // 页面来源(Referrer) referrer: document.referrer || undefined, // 当前路由路径 route: window.location.pathname, // CSP 报告中的代码样本(如有) sample: violation.sample || undefined, // 用户会话 ID sessionId: sessionId || undefined, // 会话回放链接(如有) sessionUrl: sessionUrl || undefined, // 服务名称(来自配置) serviceName: config.serviceName, // 服务版本 serviceVersion: config.serviceVersion, // 违规脚本所在的源文件 URL(如有) sourceFile: violation.sourceFile || undefined, // 当前 OpenTelemetry span ID spanId: traceContext.spanId, // HTTP 状态码(如资源加载失败时的状态码) statusCode: violation.statusCode || undefined, // 当前 OpenTelemetry trace ID traceId: traceContext.traceId, // 被违反的 CSP 指令(如 script-src) violatedDirective: violation.violatedDirective, // 事件发生的客户端时间戳 observedAt, // 以下为部署相关元数据,用于区分不同环境/版本 release: config.serviceRelease, gitCommit: config.gitCommit, gitBranch: config.gitBranch, buildId: config.buildId, releaseChannel: config.releaseChannel, environment: config.environment, // 客户端信息,方便排查浏览器兼容性 userAgent: navigator.userAgent, language: navigator.language, viewport: { width: window.innerWidth, height: window.innerHeight, }, } const antiStormResult = antiStorm.record(payload, observedAt) if (antiStormResult.decision === 'send-detail') { sendPayload(payload, antiStormResult.metadata) } else { scheduleSummaryFlush() }
// 同时往会话回放/埋点工具记录一个简化事件,方便在回放中标注 CSP 违规时刻 trackEvent('csp_violation', { blockedUri: payload.blockedUri, disposition: payload.disposition, effectiveDirective: payload.effectiveDirective, violatedDirective: payload.violatedDirective, })}
/*** CSP violations采集器。* 通过监听全局 `securitypolicyviolation` 事件,自动收集并上报 CSP 违规信息。* 需要在使用前调用 `init`,在不需要时调用 `destroy` 移除监听。*/export const securityPolicyCollector = { init(nextConfig: ObservabilityConfig): void { if (isInitialized || typeof window === 'undefined') { return }
config = nextConfig window.addEventListener('securitypolicyviolation', handleViolation) window.addEventListener('pagehide', flushOpenSummaries) isInitialized = true },
destroy(): void { if (!isInitialized || typeof window === 'undefined') { return }
window.removeEventListener('securitypolicyviolation', handleViolation) window.removeEventListener('pagehide', flushOpenSummaries) flushOpenSummaries() antiStorm.reset() isInitialized = false config = null },}/** * CSP Violation 去重/限流 模块。 * * 同一个 CSP 违规(如同一个源文件、同一行、同一指令)可能瞬间触发上百次。 * 每个客户端都这么做会浪费服务器的性能。 * * 模块通过指纹 + 时间窗口将重复违规合并为摘要,只上报少量详情。*/
// 用于生成指纹的输入字段(精选具有区分度的字段)export type CspViolationFingerprintInput = { blockedUri?: string203 collapsed lines
columnNumber?: number disposition?: 'enforce' | 'report' documentUri: string effectiveDirective: string lineNumber?: number originalPolicy: string referrer?: string route?: string sample?: string sourceFile?: string statusCode?: number violatedDirective: string}/** * 去重/限流处理后附加到每条上报的meta数据。 * 不论是详情还是聚合摘要,都会携带这些字段,方便后端按指纹聚合分析。 */export type CspAntiStormMetadata = { /** 该条违规的指纹(字符串) */ cspFingerprint: string /** 报告类型:detail(详情)或 duplicate-summary(聚合摘要) */ cspReportKind: 'detail' | 'duplicate-summary' /** 该窗口内该指纹出现的总次数(对摘要而言是抑制的总数) */ cspOccurrenceCount: number /** 聚合摘要中,被抑制的重复条数(仅摘要携带) */ cspSuppressedDuplicateCount?: number /** 去重窗口时长(毫秒) */ cspDedupeWindowMs: number /** 窗口内该指纹首次被抑制的时间(仅摘要携带) */ cspDedupeFirstObservedAt?: number /** 窗口内该指纹最后一次出现的时间(仅摘要携带) */ cspDedupeLastObservedAt?: number}/** 聚合摘要:除了元数据,还携带一条代表该指纹的典型违规样本 */export type CspDuplicateSummary = CspAntiStormMetadata & { cspReportKind: 'duplicate-summary' /** 代表该指纹的原始违规字段(用作样本) */ exemplar: CspViolationFingerprintInput}// 配置项type AntiStormOptions = { /** 去重时间窗口(毫秒) */ windowMs: number /** 同一窗口内允许发送的最大详情数量(超过后转为抑制) */ maxDetailedReportsPerWindow: number /** 最多记录的指纹数量(防止内存无限增长) */ maxFingerprints: number}// 每个指纹的内部状态type FingerprintState = { /** 该窗口内已发送的详情次数 */ detailedCount: number /** 代表该指纹的一条典型违规 */ exemplar: CspViolationFingerprintInput /** 第一次被抑制的时间(成为抑制状态时设置) */ firstSuppressedAt?: number /** 该指纹最近一次出现的时间 */ lastSeenAt: number /** 最后一次被抑制的时间(用于窗口到期时决定是否发送摘要) */ lastSuppressedAt?: number /** 累积的被抑制重复次数 */ suppressedCount: number /** 当前窗口开始时间 */ windowStartedAt: number}// flush 时可选的配置type FlushOptions = { /** 是否包含仍在进行中的窗口(如页面卸载时需强制包含) */ includeOpenWindow?: boolean}/** 将值标准化为字符串,用于拼接指纹 */const normalize = (value: string | number | undefined) => String(value ?? '')/** 浅拷贝 exemplar 对象,避免后续修改影响原始记录 */const copyExemplar = (input: CspViolationFingerprintInput): CspViolationFingerprintInput => ({ ...input,})/** * 生成 CSP 违规指纹。 * 指纹由若干高区分度字段拼接而成,确保细微变化(如行号不同)会生成不同指纹。 */export const createCspViolationFingerprint = (input: CspViolationFingerprintInput) => [ input.effectiveDirective, input.violatedDirective, input.blockedUri, input.sourceFile, input.lineNumber, input.columnNumber, input.disposition, input.documentUri, ] .map(normalize) .join('|')/** * 创建 CSP 去重/限流 实例。 * 每次违规进入时调用 `record` 判断应直接发送详情还是抑制。 * 定期调用 `flush` 将累积的抑制摘要发送出去。 */export const createCspViolationAntiStorm = (options: AntiStormOptions) => { // 指纹 -> 内部状态 const states = new Map<string, FingerprintState>() /** 当指纹数量超过上限时,踢出最久未被访问的指纹 */ const evictIfNeeded = () => { while (states.size > options.maxFingerprints) { const oldest = [...states.entries()].sort(([, a], [, b]) => a.lastSeenAt - b.lastSeenAt)[0] if (!oldest) { return } states.delete(oldest[0]) } } /** 构造详情报告的元数据 */ const buildDetailMetadata = (fingerprint: string): CspAntiStormMetadata => ({ cspFingerprint: fingerprint, cspReportKind: 'detail', cspOccurrenceCount: 1, cspDedupeWindowMs: options.windowMs, }) /** 根据内部状态构造聚合摘要(仅当存在抑制记录时) */ const buildSummary = ( fingerprint: string, state: FingerprintState, ): CspDuplicateSummary | null => { if (state.suppressedCount === 0) { return null } return { cspFingerprint: fingerprint, cspReportKind: 'duplicate-summary', cspOccurrenceCount: state.suppressedCount, cspSuppressedDuplicateCount: state.suppressedCount, cspDedupeWindowMs: options.windowMs, cspDedupeFirstObservedAt: state.firstSuppressedAt, cspDedupeLastObservedAt: state.lastSuppressedAt, exemplar: copyExemplar(state.exemplar), } } return { /** * 记录一次 CSP 违规,返回处理决策。 * @param input - 指纹输入字段 * @param now - 当前时间戳 * @returns 决策:'send-detail'(立即发送详情)或 'suppress-duplicate'(抑制) */ record(input: CspViolationFingerprintInput, now: number) { const fingerprint = createCspViolationFingerprint(input) const existing = states.get(fingerprint) // 无记录,或上次窗口已过期 → 开启新窗口,按详情发送 if (!existing || now - existing.windowStartedAt >= options.windowMs) { states.set(fingerprint, { windowStartedAt: now, detailedCount: 1, suppressedCount: 0, lastSeenAt: now, exemplar: copyExemplar(input), }) evictIfNeeded() return { decision: 'send-detail' as const, metadata: buildDetailMetadata(fingerprint) } } existing.lastSeenAt = now // 始终更新 exemplar 为最新违规,以便摘要反映最新样本 existing.exemplar = copyExemplar(input) // 还在窗口内,且详情未打满 → 允许发送详情 if (existing.detailedCount < options.maxDetailedReportsPerWindow) { existing.detailedCount += 1 return { decision: 'send-detail' as const, metadata: buildDetailMetadata(fingerprint) } } // 详情已打满,抑制后续违规,并更新抑制计数 existing.suppressedCount += 1 existing.firstSuppressedAt ??= now // 记录首次抑制时间 existing.lastSuppressedAt = now return { decision: 'suppress-duplicate' as const, fingerprint } }, /** * 刷出已完成的聚合摘要(窗口过期或页面卸载时调用)。 * @param now - 当前时间 * @param flushOptions - 是否强制包含未结束窗口 * @returns 需要发送的聚合摘要数组 */ flush(now: number, flushOptions: FlushOptions = {}) { const summaries: CspDuplicateSummary[] = [] for (const [fingerprint, state] of states.entries()) { if (state.suppressedCount === 0) { continue // 无抑制记录,无需生成摘要 } // 除非强制包含,否则仅处理已过期的窗口 if (!flushOptions.includeOpenWindow && now - state.windowStartedAt < options.windowMs) { continue } const summary = buildSummary(fingerprint, state) if (summary) { summaries.push(summary) } states.delete(fingerprint) // 发送后清理该指纹状态 } return summaries }, /** 重置所有内部状态(用于 destroy 或测试清理) */ reset() { states.clear() }, }}6.2 Transport传输至Signoz
import type { CspViolationPayload, ObservabilityConfig } from '../types'
/*** 从观测配置中解析出前端观测服务的基础 URL。* 按优先级依次尝试:frontendObservabilityEndpoint →* sourcemapResolverEndpoint → otelTracesEndpoint →* otelEndpoint,均未配置时返回空字符串。*/function getFrontendObservabilityEndpoint(config: ObservabilityConfig): string { return ( config.frontendObservabilityEndpoint || config.sourcemapResolverEndpoint ||39 collapsed lines
config.otelTracesEndpoint || config.otelEndpoint || '' )}
/** * 将 CSP 违规报告发送到后端采集接口。 * * @param payload - 已序列化的 CSP 违规数据 * @param config - 观测配置,用于获取上报地址及 debug 开关 * * 特性: * - 使用 `keepalive: true` 确保页面关闭/导航时请求仍能发出。 * - 仅在 `config.debug` 开启时输出警告,避免生产环境日志噪音。 */export async function sendCspViolationReport( payload: CspViolationPayload, config: ObservabilityConfig,): Promise<void> { const endpoint = getFrontendObservabilityEndpoint(config)
try { const response = await fetch(`${endpoint}/v1/security/csp-reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true, })
if (!response.ok && config.debug) { console.warn('[SecurityReport] Failed to send CSP violation:', response.status) } } catch (error) { if (config.debug) { console.warn('[SecurityReport] Failed to send CSP violation:', error) } }}如果你的项目中有基本的Observability基建,到这就差不多了,这并不是可以直接复制粘贴的东西,仅展示我当前项目如何记录CSP violation
6.3 Signoz看板
- 新建一个Dashboard,Name设置为Frontend CSP Violations
- Panel 选择 Table
- 将默认的Metrics改为Logs
- Filter: body CONTAINS ‘CSP violation’
- Group By: csp.effective_directive, csp.source_file,csp.blocked_url,service.release,security.channel
- Order By: count() desc
主要是做到去重,将真正的source_file之类的信息展示,然后自行分析这些文件,看是否需要处理,还是忽略,最后再收口。