10301 字
52 分钟
CSP 实践:从 Response Header 到 Trusted Types

1. CSP介绍#

Content Security Policy (CSP)是一种让浏览器针对特定网站执行自定义限制的安全策略。

通过 HTTP 响应头返回的 CSP 决定:

  1. 哪些脚本可以执行
  2. 哪些图片可以加载
  3. 哪些接口可以请求
  4. 哪些 iframe 可以嵌入
  5. 哪些页面可以把当前页面嵌入进 iframe
  6. 哪些危险能力可以禁掉

1.1 CSP指令#

既然在介绍中说到了允许哪些资源可以加载,那么我们该如何判断我们应该放行哪些,又限制哪些呢?

下面放一个指令集合,方便对CSP中的指令的含义进行一个参考:

指令可选值说明
default-srcself, none, https域名, https:, data:, blob:默认资源加载策略。
base-uriself, none, https域名限制 base 标签可使用的 URL 来源。
form-actionself, none, https域名限制表单提交目标。
frame-ancestorsself, none, https域名限制哪些页面可以嵌入当前页面。
object-srcnone, self, https域名限制 object、embed、applet 等插件类资源。
script-srcself, none, https域名, unsafe-inline, unsafe-eval, strict-dynamic, nonce, sha256, report-sample限制 JavaScript 来源和执行方式,如果没有设置的话,会回退到 default-src。
style-srcself, none, https域名, unsafe-inline, nonce, sha256, report-sample限制 CSS 来源以及内联样式。
img-srcself, none, https域名, https:, data:, blob:限制图片来源。
font-srcself, none, https域名, data:限制字体来源。
connect-srcself, none, API域名, WebSocket域名限制 Fetch、XHR、WebSocket 等连接目标。
worker-srcself, none, https域名, blob:限制 Worker 脚本来源,如果没有设置的话,浏览器会回退到script-src
frame-srcself, none, https域名限制当前页面可以加载哪些 iframe 或 frame。
manifest-srcself, none, https域名限制 Web App Manifest 文件来源。
report-uri相对路径, HTTPS上报地址CSP 违规报告上报地址。
report-tocsp-endpointReporting 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 sourceimg-src, font-src允许 data URL,需要写成data: 这种source expression格式。
blob:scheme sourceimg-src, worker-src允许浏览器运行时创建的 Blob URL,需要写成blob: 这种source expression格式
wss域名host sourceconnect-src允许指定 WebSocket 地址。比如说wss://domain.example.com
’unsafe-inline’keyscript-src, style-src允许内联脚本或内联样式,不推荐使用。
‘unsafe-eval’keyscript-src允许 eval 和 new Function 等动态代码执行,不推荐使用。
‘strict-dynamic’keyscript-src只要一个脚本是通过 NonceHash 被验证为合法的,那么这个脚本通过 document.createElement(‘script’) 动态插入到页面里的任何新脚本,都会被浏览器自动信任,无需再查白名单。

为了保证安全性,一旦浏览器检测到 strict-dynamic,它会自动忽略 script-src 里的所有域名白名单(如 https://、‘self’、example.com)。它强迫你使用更安全的 Nonce 或 Hash。

它需要搭配 nonce/hash 后才有意义。
noncenonce sourcescript-src, style-src允许带匹配 nonce 属性的内联脚本或样式,注意,要写成nonce-<value>这种形式。
简单地说,就是让脚本带上一个每次请求都不同的随机数,符合该随机数的脚本才能运行。在必须使用内联脚本的场景下,它比域名限制更灵活、更安全,但工程成本更高,简单的限制能做好的工作就不要依靠nonce,同时nonce本身也会挑部署方式。
sha256hash sourcescript-src, style-src允许内容 hash 匹配的内联脚本或样式,注意,要写成sha256-<base64>这种形式。
跟nonce不同,它是让脚本带上自己的内容指纹,也就是通过Hash来确定脚本内容的唯一性,符合哈希的脚本才能被加载。不过当脚本变动的时候就要重新计算哈希,在CI/CD流程中要做维护。
同样,普通域名拦截就能做好的事情,就不要动用更复杂的feature。
‘unsafe-hashes’keyscript-src, style-src配合 hash 允许特定内联事件处理器或样式属性,不推荐使用。
‘report-sample’keyscript-src, style-srcviolation报告中附带一小段violation代码样本。

scheme source 末尾不带 //

2. 实践#

看到上面那么多指令以及指令的可选值,该怎么使用呢?

先区分使用场景。

场景谁返回 HTMLCSP 通常配置在哪里示例
静态 SPA + NginxNginxNginx response headerVue / React 打包成 dist,Nginx 托管
静态 SPA + CaddyCaddyCaddy response headerCaddy header 配置
静态 SPA + CDNCDN / 边缘平台CDN response header / edge rulesCloudflare、AWS CloudFront、Vercel static headers
SSR 应用Node SSR 服务或框架服务SSR 服务 response headerNext.js、Nuxt、SvelteKit、自写Express SSR
后端模板渲染后端 Web 框架后端中间件或响应头配置Spring MVC、Laravel、Django、Rails
BFF / API Gateway 返回 HTMLBFF / GatewayBFF / Gateway response headerNode BFF、Spring、Cloud Gateway
Serverless 页面函数Serverless functionfunction response headersVercel Functions、Netlify、Functions、Cloudflare Workers
纯 HTML 无法改服务器头HTML 文件本身meta http-equiv只适合部分 CSP 场景,不推荐作为主方案

2.1 静态SPA#

对于静态SPA,CSP通常放在NginxCaddyCDNEdge里头,亦或是后端中间件或者响应头的配置。

在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_headerNginx 指令给响应添加 HTTP Header。
Content-Security-Policy-Report-OnlyHTTP 响应头CSP 的只报告不拦截模式。违反策略时浏览器会上报,但不会阻止资源加载。
Content-Security-PolicyHTTP 响应头与上面的二选一,正式进入拦截模式,违反策略时也会上报,但会阻止资源加载。
alwaysNginx 参数表示尽可能在各类响应状态码中都添加该 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”
OperationSet static
Header nameContent-Security-Policy-Report-Only 或 Content-Security-Policy
Header value完整 CSP 字符串,也就是之前给出的一大串
额外 Header如果用 report-to,还要加 Reporting-Endpoints

Cloudflare Response Header Transform Rules 支持设置、添加、删除响应头,也可以通过 dashboardAPITerraform 创建。

Transform Rules 要求域名 DNS 记录Cloudflare 代理。

可以自行查看Cloudflare Response Header Transform RulesTransform 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,应在这里生成,并且同时写入 HeaderHTML

注意: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 必须由能改 HTMLSSR / 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-urireport-to 做兼容。

HTML Meta 不适合承担 CSP上报能力,这也是它不适合作为主方案的原因之一。

原因
已被标记为 deprecatedMDN 明确标注 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。

参考:

MDN report-uri 文档

CSP Level 3:report-uri 被 report-to 替代#6.5.1 report-uri

CSP Level 3:report-uri 的请求结果会被忽略#5.5 Report a violation

CSP Level 3:report-uri 单请求模式不具备可扩展性#5.5 Report a violation

W3C Reporting API:浏览器报告机制本身是 best-effort

3. 一些你可能用得上的命令#

如果没有rg工具,建议自行配置,或使用别的工具,文中仅以rg作为示例。

3.1 扫描源码中是否存在影响CSP切换的高风险模式#

Terminal window
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 影响
运行时远程 CDNunpkg、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 扫描构建产物#

Terminal window
pnpm build
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*['\"\`]" dist

跟上面一样,只不过是在build之后将扫描目录从src目录换成了dist目录

3.3 分开扫描远程 CDN#

如果只是想扫描CDN依赖:

Terminal window
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 分开扫描动态执行#

Terminal window
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 APIpolicy 机制。

Trusted Types的作用是:禁止把普通字符串直接写入危险 DOM API,要求必须传入由受信任 policy 创建的 TrustedHTMLTrustedScriptTrustedScriptURL

典型危险 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 Typesrequire-trusted-types-for ‘script’
trusted-types限制页面允许创建哪些 Trusted Types policytrusted-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-htmlpolicy在项目中被声明。

如下,这是一个最小应用示例。

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 验证
sanitizerTrusted Types 仅强制走 policy,并不自动清洗内容。真正的清洗逻辑需要使用 DOMPurify等 sanitizer。
先Report-Only直接 enforce 使用sanitizer做清洗,可能部分依赖库或富文本、Markdown、编辑器等功能会被限制,建议先观察再调整。
policy范围不建议长期使用 trusted-types *,用上了就做细一些,否则没什么意义。
default policydefault policy 会吞掉很多问题,并不建议使用,建议直接创建显式policy。

应用的开放程度会影响 CSP 与 Trusted Types 的优先级,但不会改变其技术价值。

B 端系统通常风险暴露面更小,但并不意味着可以忽略 DOM XSS、第三方依赖行为或误配置带来的问题。只不过优先级可以往后挪,等产品上线后边测边做。

5.1 注入时机#

很多 DOM XSS 并不是在接收到用户输入时发生,而是在运行时把字符串写入危险 DOM API 时发生。

例如:

element.innerHTML = userInput

这里真正的风险点不是 userInput 变量本身,而是它进入了 innerHTML 这个危险 sink。

同理,evalnew Functionscript.srcWorker(blob:...) 等场景,本质上也都是“字符串在某个运行时刻被解释为可执行内容”。

这也是为什么 Trusted Types 要限制的不是用户输入本身,而是普通字符串进入危险 sink 的方式。

5.2 Policy的创建#

开启 Trusted Types 后,浏览器会告诉开发者危险sink不能直接传递string,必须传递TrustedHTML / TrustedScriptURL

// 错误:不能直接传普通 string
element.innerHTML = userInput

对于创建TrustedHTML / TrustedScriptURLTrusted 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
trusted-types.ts
// 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,一个是ScriptURL
189 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 | undefined
let trustedTypesPolicyFactory: TrustedTypesPolicyFactory | null | undefined
// 调用方不能直接传字符串给 HTML policy,而要先wrap。
export const asSanitizedHtmlInput = (value: string): SanitizedHtmlInput => ({
kind: 'sanitized-html',
value,
})
// 获取从factory中得到的TrustedHTML
export 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 SanitizedHtmlInput
function 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 policy
export 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出来一个worker
type 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 代码。

然后我们针对 markdownhtml 表达以及 markdown 内置的 mermaid 做简单的 sanitize

这里主要是:渲染后,进入DOM前。

markdown-security.ts
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组件:

md-editor-loader.ts
import { defineComponent, h, type Component } from 'vue'
import { sanitizeMarkdownHtml, sanitizeMarkdownMermaid } from './markdown-security'
// 运行时promise缓存,防止同一套runtime被重复初始化,多个editor组件同时出现时只初始化一次。
let mdPreviewRuntimePromise: Promise<void> | null = null
let 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#

security-policy-collector.ts
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-anti-storm.ts
/**
* CSP Violation 去重/限流 模块。
*
* 同一个 CSP 违规(如同一个源文件、同一行、同一指令)可能瞬间触发上百次。
* 每个客户端都这么做会浪费服务器的性能。
*
* 模块通过指纹 + 时间窗口将重复违规合并为摘要,只上报少量详情。
*/
// 用于生成指纹的输入字段(精选具有区分度的字段)
export type CspViolationFingerprintInput = {
blockedUri?: string
203 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#

security-report-transport.ts
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之类的信息展示,然后自行分析这些文件,看是否需要处理,还是忽略,最后再收口。

CSP 实践:从 Response Header 到 Trusted Types
https://blog.astro777.cfd/posts/guide/csp-practice/
作者
ASTRO
发布于
2026-04-27
许可协议
CC BY-NC-SA 4.0