跳到主内容

从告警到合规: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 的请求链路 ​

浏览器请求页面GET /index服务端生成 noncecrypto.webcrypto写入 CSP 响应头script-src nonceHTML 标签注入nonce 属性浏览器校验脚本无属性则拦截上报违规报告report-uri / report-to

关键点是: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 和强制策略。

上线步骤:先报告、再阻断、持续监控 ​

建议按四阶段推进:

  1. 盘点资源:用浏览器 DevTools Network 面板、打包分析和代理日志,列出脚本、接口、图片、字体、iframe 来源;
  2. 报告模式:先下发 Content-Security-Policy-Report-Only,不阻断业务,只收集 1 到 2 周违规报告;
  3. 灰度强制:对内部环境或低风险流量开启 Content-Security-Policy,观察白屏率、SDK 初始化成功率和核心转化;
  4. 持续治理:把 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 或边缘动态注入解决静态站问题,第三方能力按最小权限拆分白名单,再通过报告持续收敛,才能兼顾安全、兼容性与可维护性。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。