内容安全策略 (CSP) 实施

中等 🟡前端安全
12 个标签
预计阅读时间:51 分钟
前端安全CSP内容安全策略安全配置nonceXSS防护HTTP头部strict-dynamicTrusted TypesSRI违规上报CSP绕过

内容安全策略 (CSP) 实施

内容安全策略 (Content Security Policy, CSP) 是一种由浏览器强制执行的安全机制。它通过一份"白名单"告诉浏览器:这个页面只允许从哪些来源加载脚本、样式、图片、字体等资源,以及是否允许执行内联脚本。凡是不在白名单里的资源,浏览器会直接拒绝加载或执行。

可以把 CSP 想象成一家高级会所的门禁:即使有人偷偷把一张伪造的邀请函(恶意脚本)塞进了信封(页面 HTML),门口的保安(浏览器)也会拿着宾客名单(CSP 策略)逐一核对,名单上没有的人一律不放行。这样即便攻击者成功注入了脚本,脚本也可能因为"不在名单上"而无法执行或无法把窃取的数据发回攻击者服务器。

为什么 CSP 重要

XSS(跨站脚本)长期霸占 OWASP Top 10。传统防御依赖"输入过滤 + 输出编码",但只要有一处遗漏,攻击者就能注入脚本。CSP 提供的是"纵深防御"的最后一道防线——即使前面的编码环节被绕过,CSP 仍能阻断脚本执行。

Google 内部数据显示,部署严格的基于 nonce 的 CSP 后,可缓解约 95% 的反射型和存储型 XSS 利用。
根据 HTTP Archive 的统计,头部网站中部署 CSP 的比例逐年上升,但其中很大一部分因为使用了 `unsafe-inline` 而形同虚设。
CSP 不仅能防 XSS,还能防点击劫持(`frame-ancestors`)、混合内容、表单劫持(`form-action`)以及数据外泄(`connect-src`)。

CSP 基本概念

定义:

浏览器级别的安全机制,运行在客户端
通过 HTTP 响应头 `Content-Security-Policy` 或 HTML `` 标签下发
限制资源的加载来源与执行方式
违规时可选择"阻止"或"仅报告"

核心目标:

大幅降低 XSS 攻击的成功率与危害
防止敏感数据被恶意脚本外发
阻止页面被非授权站点嵌入(防点击劫持)
提供违规上报能力,实现安全监控

工作原理:

1.浏览器接收到响应头中的 CSP 指令并解析
2.页面每尝试加载或执行一项资源,浏览器都会对照策略校验
3.不符合策略的资源被阻止(控制台报错)
4.若配置了上报地址,浏览器把违规详情 POST 到指定端点

CSP 指令详解

资源来源指令(Fetch Directives):

`default-src`:兜底来源,其他 `*-src` 未显式声明时回退到它
`script-src`:JavaScript 来源,安全防线的核心
`style-src`:CSS 样式来源
`img-src`:图片来源
`font-src`:字体来源
`media-src`:音视频来源
`connect-src`:XHR / fetch / WebSocket / EventSource 的目标地址(控制数据能发到哪)
`frame-src`:iframe 内嵌页面来源
`worker-src`:Web Worker / Service Worker 来源
`manifest-src`:manifest 文件来源

文档与导航指令:

`base-uri`:限制 `` 标签,防止相对路径被劫持
`form-action`:限制表单可提交的目标地址,防表单劫持
`frame-ancestors`:谁可以用 iframe 嵌入本页面,替代 X-Frame-Options 防点击劫持
`sandbox`:为页面开启沙箱限制

报告指令:

`report-uri`:(旧)违规报告接收地址
`report-to`:(新)配合 `Reporting-Endpoints` 头使用的报告组
`upgrade-insecure-requests`:自动把 http 请求升级为 https

常用来源关键字:

`'self'`:与页面同源
`'none'`:什么都不允许
`'unsafe-inline'`:允许内联脚本/样式(危险,尽量避免)
`'unsafe-eval'`:允许 `eval`、`new Function` 等(危险)
`'nonce-xxxx'`:允许携带匹配随机数的内联脚本
`'sha256-xxxx'`:允许哈希匹配的内联脚本
`'strict-dynamic'`:信任已被 nonce/hash 授权的脚本动态加载的子脚本

代码示例

示例 1:Next.js 中间件下发基于 nonce 的严格 CSP

javascriptCode
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 每次请求生成一个一次性随机数
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  const csp = [
    `default-src 'self'`,
    // strict-dynamic 让被 nonce 授权的脚本可以再动态加载其他脚本
    `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https:`,
    `font-src 'self'`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `upgrade-insecure-requests`,
  ].join('; ');

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}

示例 2:在服务端组件里读取 nonce 并注入内联脚本

javascriptCode
import { headers } from 'next/headers';

export default function Page() {
  const nonce = headers().get('x-nonce') ?? '';
  return (
    <html>
      <body>
        <h1>受 CSP 保护的页面</h1>
        {/* 只有携带正确 nonce 的内联脚本才会被执行 */}
        <script nonce={nonce} dangerouslySetInnerHTML={{ __html: 'window.__APP__ = true;' }} />
      </body>
    </html>
  );
}

示例 3:Express + helmet 配置 CSP

javascriptCode
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');

const app = express();

// 为每个请求生成 nonce
app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [
        "'self'",
        (req, res) => `'nonce-${res.locals.nonce}'`,
        "'strict-dynamic'",
      ],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://api.example.com'],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      frameAncestors: ["'none'"],
      reportUri: ['/csp-report'],
    },
  })
);

示例 4:使用哈希(hash)授权固定的内联脚本

javascriptCode
// 对于内容固定、无法加 nonce 的内联脚本,可用其 SHA-256 哈希授权
const crypto = require('crypto');
const inlineScript = 'console.log("hello csp");';
const hash = crypto.createHash('sha256').update(inlineScript).digest('base64');
console.log(`script-src 'sha256-${hash}'`);
// 输出示例: script-src 'sha256-xxxxxxxxxxxxxxxx...'

示例 5:接收并解析 CSP 违规报告

javascriptCode
// Express 中接收违规上报
app.post('/csp-report', express.json({ type: ['application/csp-report', 'application/json'] }), (req, res) => {
  const report = req.body['csp-report'] || req.body;
  console.warn('[CSP 违规]', {
    documentUri: report['document-uri'],
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    sourceFile: report['source-file'],
    lineNumber: report['line-number'],
  });
  res.status(204).end();
});

CSP 实施策略

严格策略(推荐):

使用 nonce 或 hash 授权内联脚本,彻底避免 `unsafe-inline`
配合 `strict-dynamic`,不再依赖脆弱的域名白名单
禁止 `unsafe-eval`
适合金融、政企等安全要求高的场景

宽松策略(过渡期):

暂时允许部分来源甚至 `unsafe-inline`
适合从零迁移的老项目
目标是逐步收紧,不能长期停留

平滑迁移三步走:

1.先用 `Content-Security-Policy-Report-Only` 上线,只报告不拦截
2.收集数周的违规报告,分析哪些是正常业务、哪些是异常
3.逐步调整白名单,确认无误后切换为强制模式 `Content-Security-Policy`

真实案例

案例一:GitHub 的 CSP 实践

GitHub 是最早大规模落地严格 CSP 的平台之一。他们采用基于 nonce 的策略,几乎完全禁用了 `unsafe-inline`,把大量本可导致 XSS 的注入点变成了无害的文本。GitHub 工程团队公开分享过,CSP 让他们即便偶有 XSS 漏洞被发现,实际可利用性也极低。

案例二:某电商因误用 unsafe-inline 导致 CSP 失效

某电商上线了 CSP,看似合规,却在 `script-src` 中同时写了 `'unsafe-inline'`。结果攻击者通过存储型 XSS 注入内联脚本,浏览器因为 `unsafe-inline` 照常执行,CSP 完全没起作用。教训:白名单里只要出现 `unsafe-inline`,脚本类防护基本归零(nonce/hash 会使浏览器忽略 unsafe-inline,是更安全的写法)。

案例三:report-only 提前发现第三方脚本外发数据

某内容站点在灰度阶段用 report-only 模式跑了两周,从违规报告里发现一个引入的第三方统计 SDK 偷偷向未申报域名发送数据。团队据此及时替换了 SDK,避免了正式上线后的数据合规事故。

数据与对比

| 策略写法 | 抗 XSS 能力 | 维护成本 | 说明 |

| --- | --- | --- | --- |

| 无 CSP | 无 | 无 | 完全依赖编码防护 |

| default-src self + unsafe-inline | 很弱 | 低 | 脚本防护基本失效 |

| 域名白名单(无 nonce) | 中 | 高 | 白名单易被 JSONP/开放跳转绕过 |

| nonce + strict-dynamic | 强 | 中 | Google 推荐的现代方案 |

| hash 授权固定脚本 | 强 | 中高 | 适合内容固定的内联脚本 |

| 下发方式 | 支持 report-uri | 灵活性 | 适用场景 |

| --- | --- | --- | --- |

| HTTP 响应头 | 支持 | 高,可动态生成 nonce | 生产环境首选 |

| meta 标签 | 不支持 | 低,无法防 frame-ancestors | 纯静态站点兜底 |

常见坑

误加 unsafe-inline 让策略形同虚设:这是最常见的错误,脚本类应改用 nonce/hash。
nonce 复用:nonce 必须每次请求随机生成,若全站写死或缓存,等于没有随机性,可被预测。
meta 标签无法配置 frame-ancestors 与 report-uri:涉及防点击劫持和上报,必须用 HTTP 头。
忘记 connect-src 导致数据可任意外发:即使脚本被限制,若 `connect-src` 过宽,被注入脚本仍可能把数据 fetch 到攻击者服务器。
第三方脚本引入大量白名单:每加一个域名都是攻击面,优先用 strict-dynamic 减少域名依赖。
未处理 report-only 与强制模式的差异:report-only 不会拦截,切记正式防护要用 `Content-Security-Policy`。
inline 事件处理器(onclick 等)被拦截:严格 CSP 下 `onclick="..."` 会失效,需改为 addEventListener。

最佳实践

资源管理:

优先使用 nonce + strict-dynamic,彻底摆脱域名白名单的脆弱性
坚决避免 `unsafe-inline` 和 `unsafe-eval`
引用 CDN 时指定精确来源,警惕开放 CDN 上的旧库被利用

报告与监控:

上线前先用 report-only 收集数据
搭建违规上报端点并接入告警,持续观察异常
定期复盘白名单,删除不再使用的来源

框架集成:

React:谨慎使用 `dangerouslySetInnerHTML`,配合 DOMPurify 清理
Vue:`v-html` 仅用于可信内容,用户输入必须先净化
Angular:内置 CSP 支持较好,善用 `DomSanitizer`

测试与验证:

用 Google CSP Evaluator 检查策略是否存在可绕过项
用 Mozilla Observatory 做整体安全评分
覆盖多浏览器兼容性测试

工具与资源

CSP Evaluator(Google):分析策略安全性,识别可绕过的配置
Mozilla Observatory:网站整体安全体检,含 CSP/HSTS/Cookie 评分
MDN CSP 文档:权威语法与指令参考
OWASP CSP Cheat Sheet:企业级最佳实践
report-uri.com:托管式违规上报与分析平台

CSP 三代演进:Level 1 / 2 / 3

CSP 并非一蹴而就,而是经过三次大版本迭代逐步成熟。理解每一代解决了什么问题、引入了什么新能力,能帮助你判断"某个指令能不能用""浏览器会不会支持"。

Level 1(2012 年 W3C 候选推荐):

奠定基础,引入 `default-src`、`script-src`、`style-src`、`img-src` 等最核心的 fetch 指令
支持 `'self'`、`'none'`、`'unsafe-inline'`、`'unsafe-eval'` 关键字
只能靠域名白名单控制脚本来源,一旦白名单里有可被利用的域名(如带 JSONP 的 CDN)就会被绕过
支持 `report-uri` 上报

Level 2(2016 年 W3C 推荐):

引入 `'nonce-xxx'` 和 `'sha256-xxx'` 两种"精确授权"内联脚本的方式,第一次让"禁用 unsafe-inline 又保留必要内联脚本"成为可能
新增 `base-uri`、`form-action`、`frame-ancestors`、`child-src`、`manifest-src`、`worker-src` 等指令
`frame-ancestors` 开始替代 `X-Frame-Options`

Level 3(持续演进的工作草案,主流浏览器已大量实现):

引入 `'strict-dynamic'`,让被 nonce/hash 授权的脚本可以动态加载其他脚本,从而彻底摆脱域名白名单
引入 `script-src-elem`/`script-src-attr`、`style-src-elem`/`style-src-attr`,把"标签"和"属性/事件处理器"拆开细粒度控制
引入 Trusted Types 相关的 `trusted-types` 与 `require-trusted-types-for`
引入 `report-to`(配合 `Reporting-Endpoints`)取代逐步废弃的 `report-uri`
引入 `prefetch-src`(后又在部分实现中被移除,兼容性需注意)、`navigate-to`(提案,已从主流实现撤下)

| 版本 | 发布/状态 | 标志性能力 | 主要局限 |

| --- | --- | --- | --- |

| Level 1 | 2012 候选推荐 | 基础 fetch 指令、域名白名单 | 内联脚本只能全开全关 |

| Level 2 | 2016 正式推荐 | nonce、hash、frame-ancestors | 仍依赖域名白名单,易被绕过 |

| Level 3 | 工作草案,广泛实现 | strict-dynamic、Trusted Types、report-to | 部分指令兼容性参差 |

一个实用建议:面向现代浏览器时以 Level 3 的 nonce + strict-dynamic 为主,同时保留 Level 2 的 hash 与 Level 1 的域名白名单作为老浏览器的兜底,浏览器会各取所需、互不冲突。

指令逐条深入

前面列过指令清单,这里挑几个"容易被忽略但很关键"的指令展开讲。

object-src 与 base-uri:两个必配的"堵漏"指令

`object-src` 控制 ``、``、`` 能加载的插件内容。老旧的 Flash/PDF 插件曾是 XSS 的温床,现代应用几乎不需要它们,因此强烈建议无条件写死:

javascriptCode
// 几乎所有站点都应该加上这两条
"object-src 'none'";  // 禁止一切插件资源
"base-uri 'none'";    // 或 'self',防止注入 <base href> 劫持相对 URL

`base-uri` 常被遗忘:如果攻击者能注入一个 ``,那么页面里所有相对路径的脚本、样式都会从攻击者服务器加载——即便你的 `script-src` 白名单再严格也拦不住,因为浏览器解析出的绝对 URL 变了。Google CSP Evaluator 会把"缺少 base-uri"标记为高危项。

script-src-elem 与 script-src-attr:标签与属性分治

Level 3 把脚本控制拆成了两半:

`script-src-elem`:控制 `