外观
从告警到合规:Vue3 CSP nonce 落地避坑实录
问题背景:为什么只加 script-src 还不够
内容安全策略(Content Security Policy,CSP)是浏览器提供的纵深防御机制,核心目标不是替代输入校验,而是在 XSS 真的发生时,限制恶意脚本能否执行。很多团队第一次接入时会直接配置 default-src 'self',结果页面立刻白屏:统计 SDK、客服挂件、A/B 测试脚本、内联引导脚本、样式注入全部被拦截。
在 Vue3 与 VitePress 场景中,问题更典型:框架本身通常不需要 unsafe-eval,但生态库可能动态创建 <script>;VitePress 预渲染会把首屏状态、主题配置和部分内联脚本写入 HTML;第三方脚本又经常继续加载二级域名资源。只靠域名白名单很容易越配越宽,最后退化成 'unsafe-inline' 'unsafe-eval' https:,看似有 CSP,实际防护价值很弱。
更推荐的策略是:业务自己的内联脚本使用一次性 nonce,第三方域名使用最小白名单,优先上 Content-Security-Policy-Report-Only 观察,再切换为强制模式。
方案对比:nonce、hash 与域名白名单怎么选
| 方案 | 适合内容 | 优点 | 主要限制 |
|---|---|---|---|
'nonce-随机值' | 服务端或边缘节点渲染的内联脚本 | 可批量授权,安全性高 | 每次响应都要生成不可预测随机值,不能泄露到缓存键外 |
'sha256-摘要' | 内容固定的短脚本 | 静态站点友好 | 脚本内容改一个字符就失效,维护成本高 |
| 域名白名单 | 可信第三方 SDK | 清晰可控 | 必须继续限制 script-src 与 connect-src,不能滥用通配符 |
'unsafe-inline' | 临时兼容 | 接入快 | 基本抵消 CSP 对内联脚本的防护能力 |
对于 Vue3 SSR 或 Node 服务,优先使用 nonce;对于纯静态 VitePress,如果内联脚本内容固定,可以用 hash;如果页面由 CDN 边缘函数动态注入脚本,则在边缘生成 nonce,并保证 HTML 不被错误缓存。
CSP nonce 的请求链路
关键点是:CSP 头中的 nonce 与 HTML 内联脚本上的 nonce 属性必须完全一致,并且每个响应重新生成。不要把 nonce 放进 URL、localStorage 或可被第三方脚本读取的全局变量。
Vue3 SSR:为内联脚本注入一次性 nonce
下面示例使用 Node 18+ 内置 Web Crypto 生成 16 字节随机数。生产中可以放在 Express、Nitro、Egg.js 或边缘函数中,重点是把同一个 cspNonce 同时传给 CSP 响应头和 Vue 渲染上下文。
javascript
// server.js
// node server.js,依赖:express、vue、@vue/server-renderer
import express from 'express'
import crypto from 'node:crypto'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const app = express()
app.get('/', async (req, res) => {
// 每次请求生成新的 nonce;base64 中可能出现 + / =,需替换为 CSP 安全字符
const cspNonce = crypto.webcrypto.getRandomValues(new Uint8Array(16))
.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
const vueApp = createSSRApp({
data: () => ({ count: 1 }),
template: `
<main>
<h1>Vue3 CSP nonce 示例</h1>
<button @click="count++">点击 {{ count }}</button>
</main>
`
})
const appHtml = await renderToString(vueApp, { nonce: cspNonce })
// 仅列出关键指令;上线前先用 Report-Only 观察
const csp = [
`default-src 'self'`,
`script-src 'nonce-${cspNonce}' 'strict-dynamic' https://www.googletagmanager.com`,
`style-src 'self' 'nonce-${cspNonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://example.com https://region.analytics.com`,
`frame-src https://www.example-widget.com`,
`object-src 'none'`,
`base-uri 'self'`,
`report-uri /csp-report`
].join('; ')
res.setHeader('Content-Security-Policy-Report-Only', csp)
res.send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue3 CSP Demo</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script nonce="${cspNonce}" type="module" src="/assets/client.js"></script>
</body>
</html>`)
})
app.post('/csp-report', express.json({ type: 'application/reports+json' }), (req, res) => {
console.warn('CSP violation:', JSON.stringify(req.body))
res.status(204).end()
})
app.listen(3000, () => console.log('http://localhost:3000'))这里有两个细节。第一,renderToString 的上下文中传入 nonce 后,Vue 服务端渲染生成的 teleport、suspense 等内联脚本会带上该属性。第二,'strict-dynamic' 允许被 nonce 信任的引导脚本继续加载已构建的业务 chunk,但不会让页面中任意插入的第三方脚本自动获得信任,因此比开放一堆域名更安全。需要注意,老版本 Safari 对 strict-dynamic 支持不一致,兼容期可同时保留必要的 https 白名单。
第三方脚本白名单:不要只放行 script-src
第三方脚本不是只下载一个 JS 文件。以常见统计和客服 SDK 为例,它可能还会请求数据上报接口、加载样式、字体、图片,甚至创建 iframe。因此要按能力拆分指令:
script-src:允许下载和执行哪些脚本;connect-src:允许fetch、XHR、WebSocket、sendBeacon 访问哪些接口;img-src:允许像素打点、头像和图片资源;style-src:SDK 动态注入样式时需要 nonce 或对应来源;frame-src:客服、支付、验证码等嵌入式页面;worker-src:部分 SDK 会创建 Web Worker。
可以维护一份带责任人的白名单配置,避免半年后没人知道某个域名是否还能删除。
typescript
// csp-config.ts
export type CspDirective = 'script-src' | 'connect-src' | 'img-src' | 'style-src' | 'frame-src'
export interface TrustedOrigin {
origin: string
owner: string
reason: string
directives: CspDirective[]
}
// 所有第三方来源统一登记,禁止直接写 https:
export const trustedOrigins: TrustedOrigin[] = [
{
origin: 'https://www.googletagmanager.com',
owner: 'growth',
reason: '标签管理器加载入口脚本',
directives: ['script-src']
},
{
origin: 'https://region.analytics.com',
owner: 'data',
reason: '埋点数据上报',
directives: ['connect-src', 'img-src']
},
{
origin: 'https://www.example-widget.com',
owner: 'service',
reason: '在线客服 iframe',
directives: ['frame-src']
}
]
export function buildDirective(nonce: string): string {
const originsByDirective = trustedOrigins.reduce<Record<CspDirective, string[]>>(
(acc, item) => {
for (const directive of item.directives) {
acc[directive].push(item.origin)
}
return acc
},
{
'script-src': [],
'connect-src': [],
'img-src': [],
'style-src': [],
'frame-src': []
}
)
return [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic' ${originsByDirective['script-src'].join(' ')}`,
`connect-src 'self' ${originsByDirective['connect-src'].join(' ')}`,
`img-src 'self' data: ${originsByDirective['img-src'].join(' ')}`,
`style-src 'self' 'nonce-${nonce}'`,
`frame-src ${originsByDirective['frame-src'].join(' ')}`,
`object-src 'none'`,
`base-uri 'self'`
].join('; ')
}VitePress 静态站:预渲染脚本用 hash 或边缘 nonce
VitePress 构建产物是静态 HTML,不天然存在“每个请求生成随机值”的环节。若托管平台支持边缘函数,可在响应 HTML 时替换占位符;若没有边缘能力,更简单的方式是对固定内联脚本使用 hash。
typescript
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import crypto from 'node:crypto'
// 这段脚本内容必须与 HTML 中输出完全一致,包括空格和换行
const inlineBootstrap = `window.__APP_READY__=true;`
const hash = crypto.createHash('sha256').update(inlineBootstrap).digest('base64')
export default defineConfig({
lang: 'zh-CN',
title: 'CSP 文档站',
head: [
[
'meta',
{
'http-equiv': 'Content-Security-Policy',
// 建议先在测试环境验证;style-src 可按主题实际注入情况收敛
content: [
`default-src 'self'`,
`script-src 'self' 'sha256-${hash}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`
].join('; ')
}
]
]
})生产环境更推荐把 CSP 放到网关、Nginx 或 CDN 响应头中,而不是写 <meta http-equiv>。响应头方式支持 report-uri、frame-ancestors 等指令,也便于按环境切换 Report-Only 和强制策略。
上线步骤:先报告、再阻断、持续监控
建议按四阶段推进:
- 盘点资源:用浏览器 DevTools Network 面板、打包分析和代理日志,列出脚本、接口、图片、字体、iframe 来源;
- 报告模式:先下发
Content-Security-Policy-Report-Only,不阻断业务,只收集 1 到 2 周违规报告; - 灰度强制:对内部环境或低风险流量开启
Content-Security-Policy,观察白屏率、SDK 初始化成功率和核心转化; - 持续治理:把 CSP 报告接入日志平台,按
effective-directive、blocked-uri聚合,并为第三方来源设置过期时间。
避坑总结
1. nonce 被缓存复用,导致“随机值”变成固定密钥
错误现象:CDN 缓存了第一个用户的 HTML,后续所有人拿到同一个 nonce;或者压测时接口正常,安全扫描仍提示 nonce 可预测。修正方法:在 HTML 响应头加 Cache-Control: no-store,或只在边缘节点动态替换 CSP 头与 nonce 属性;静态资源 chunk 可以长缓存,但包含 nonce 的 HTML 文档不能公共缓存。
2. 只配置 script-src,SDK 仍然功能异常
错误现象:控制台先出现脚本违规,处理后又报 Refused to connect、Refused to frame 或字体加载失败。修正方法:按浏览器违规报告逐项补齐 connect-src、img-src、frame-src、worker-src,不要为了省事直接写 default-src *。第三方脚本的完整资源域名要向供应商确认,不能只看首屏网络请求。
3. VitePress hash 因格式化变化频繁失效
错误现象:本地可用,Prettier 或 HTML 压缩后 CSP 报 'sha256-...' 不匹配。修正方法:hash 必须基于浏览器最终收到的脚本文本计算;稳定做法是把脚本抽成固定字符串常量,由构建脚本同时生成 HTML 和 CSP。频繁变化的脚本不要用 hash,改为边缘 nonce。
4. 为了兼容旧代码长期保留 unsafe-eval
错误现象:Vue 模板运行时编译、老旧模板库、开发调试包在生产环境触发 unsafe-eval 告警,团队直接加上该关键字。修正方法:Vue3 生产构建默认使用预编译渲染函数,不需要 unsafe-eval;应检查是否误引了完整版本 vue/dist/vue.global.js,把别名指向 vue/dist/vue.runtime.esm-bundler.js,并替换依赖 new Function() 的库。
5. 报告模式没验证就强制切换,造成白屏
错误现象:测试环境没有登录态和真实第三方脚本,上线后支付、验证码或客服插件全部被拦截。修正方法:强制前必须覆盖匿名用户、登录用户、移动 Web、管理后台等路径;先按页面和流量灰度,保留快速回滚到 Report-Only 的开关,并监控前端错误数与业务转化率。
CSP 落地不是一次性安全配置,而是前端资源治理。Vue3 侧用 nonce 保护业务内联脚本,VitePress 侧用 hash 或边缘动态注入解决静态站问题,第三方能力按最小权限拆分白名单,再通过报告持续收敛,才能兼顾安全、兼容性与可维护性。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。