浏览器与客户端安全:CSP、SRI 与 Trusted Types

系统讲解浏览器与客户端安全防线:内容安全策略(CSP)的 nonce 与 strict-dynamic 实践、子资源完整性(SRI)防供应链投毒、Trusted Types 根除 DOM XSS,以及 COOP/COEP/CORP 与 Permissions-Policy 的落地配置。

服务端再严,最终把 HTML、脚本和样式拼装到用户眼前的仍是浏览器。过去十年里,大量高危漏洞并不是"后端被攻破",而是浏览器按攻击者构造的指令执行了本不该执行的代码——一个被注入的内联脚本、一段来自第三方 CDN 的篡改文件、一次把字符串当 HTML 塞进 DOM 的操作,都足以绕过全部服务端校验。本文从浏览器的信任边界出发,讲清四类客户端防线:内容安全策略(Content Security Policy,CSP)、子资源完整性(Subresource Integrity,SRI)、Trusted Types,以及隔离与权限相关的响应头。

一、浏览器的信任边界与同源模型

要理解客户端防线,先要理解浏览器把"信任"画在了哪里。核心是同源策略(Same-Origin Policy,SOP):源(origin)由协议 + 主机 + 端口三元组定义,只有同源页面才能自由读取彼此的 DOM、Cookie 与存储。

机制隔离的粒度防的是
同源策略origin跨站读取数据
站点隔离(Site Isolation)渲染进程旁路攻击(Spectre 类)
沙箱(Sandbox)渲染进程能力逃逸到操作系统
CSP页面内资源与脚本注入代码执行
Trusted TypesDOM 接收点DOM-based XSS

关键认知:XSS 的本质不是"字符串里有 <script>",而是"不可信数据流到了可执行的接收点(sink)"。CSP 与 Trusted Types 分别从"执行前拦截"和"数据流约束"两个角度封堵这个通路。

一个常被忽略的边界是扩展(extension)与内嵌框架(iframe)。第三方 iframe 默认可以携带自己的脚本与 Cookie,因此对外部内容的嵌入必须用 sandbox 属性收紧:

<!-- 允许脚本但不允许同源访问、表单提交与顶层导航 -->
<iframe src="https://third-party.example/widget"
        sandbox="allow-scripts"
        referrerpolicy="no-referrer"
        loading="lazy"></iframe>

sandbox 的取值是白名单式的,什么都不写等于全部禁止;allow-scripts 与 allow-same-origin 同时出现会让沙箱形同虚设,务必避免。

二、内容安全策略(CSP)

CSP 通过一个 HTTP 响应头(或 <meta> 标签,但能力受限)声明"本页面允许加载和执行什么"。它是最有效的纵深防御之一,也是配置最容易翻车的一层。

2.1 从黑名单到 nonce:为什么 unsafe-inline 是陷阱

早期实践常用 script-src 'self' cdn.example.com 做域名白名单。问题是:只要白名单里任何一个域名存在 JSONP 端点或可上传的脚本文件,攻击者就能借它执行任意代码,白名单形同虚设。

CSP Level 3 给出的答案是 nonce + strict-dynamic:

add_header Content-Security-Policy "
  default-src 'none';
  script-src 'nonce-$request_id' 'strict-dynamic' https:;
  style-src 'self' 'nonce-$request_id';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  font-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  report-uri /csp-report;
" always;

要点逐条解释:

  • script-src 'nonce-xxx':只有带匹配 nonce 属性的 <script> 才执行,内联脚本也需带该属性。nonce 必须每次请求随机生成,不可复用、不可缓存。
  • 'strict-dynamic':一旦脚本通过 nonce 加载,它动态创建的 <script> 也被信任。这解决了 SPA 里打包器动态注入 chunk 的难题,同时让域名白名单在支持该指令的浏览器里被忽略。
  • object-src 'none':禁用 Flash/插件类嵌入,历史漏洞重灾区。
  • base-uri 'none':防止 <base> 标签劫持相对路径。
  • frame-ancestors 'none':等价于 X-Frame-Options: DENY,防点击劫持。

2.2 报告与渐进式上线

直接上强制策略极易打挂线上页面。正确姿势是先用 Content-Security-Policy-Report-Only 收集违规,观察一段时间后再切强制。

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

report-uri 已被 report-to 取代,后者配合 Reporting-Endpoints 头支持采样与重试:

Reporting-Endpoints: csp-endpoint="https://example.com/reports"
Content-Security-Policy: default-src 'self'; report-to csp-endpoint

收集到的报告里 blocked-uri、violated-directive、source-file 是定位元凶的关键字段。把报告接入日志系统(见 可观测性 )后才能形成闭环。

2.3 常见踩坑

  • style-src 内联样式:很多 UI 框架运行时注入 <style>,需要 'unsafe-inline' 或改用 CSSStyleSheet 构造。用 nonce 是更干净的选择。
  • 'unsafe-eval':只要出现,CSP 的脚本防线基本报废。现代打包器已不需要 eval。
  • 多值头合并:多个中间件各 add_header 会产生多个 CSP 头,浏览器取交集,常导致页面莫名失效。用 map 统一管理。
  • CDN 回源改写:某些 WAF/CDN 会改写 HTML 并注入脚本,破坏 nonce,需要把这类改写关掉或加入哈希白名单。

2.4 服务端 nonce 生成与模板注入

nonce 的随机性直接决定 CSP 强度,必须用密码学安全随机数(CSPRNG),长度至少 128 位,并且每个响应唯一。Node.js 侧:

import crypto from 'node:crypto';

app.use((req, res, next) => {
  // 16 字节 → base64 后 24 字符,满足 128 位熵
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader('Content-Security-Policy', [
    `default-src 'none'`,
    `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${res.locals.nonce}'`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `frame-ancestors 'none'`,
  ].join('; '));
  next();
});

模板里把 nonce 写进每一个 <script>:

<script nonce="{{ nonce }}" src="/assets/app.js"></script>

Java(Spring Boot)侧的等价写法是把 nonce 放进 HttpServletRequest 属性,用 Thymeleaf 的 th:attr="nonce=${nonce}" 注入。要点是同一个响应里所有内联脚本共用一个 nonce,并且该 nonce 不能出现在任何可被攻击者读取的地方(比如 JSON 响应或日志)。

2.5 CSP 指令速查

指令作用推荐值
default-src其余指令的兜底'none'
script-src脚本来源'nonce-…' 'strict-dynamic'
style-src样式来源'self' 或 nonce
img-src图片来源'self' data: https:
connect-srcfetch/XHR/WebSocket显式列出 API 域
frame-ancestors谁能嵌入本页'none'
form-action表单提交目标'self'
base-uri<base> 取值'none'
upgrade-insecure-requests自动升级 HTTP存在即可

三、子资源完整性(SRI)与供应链防线

CSP 管的是"允许从哪加载",SRI 管的是"加载到的字节是否被篡改"。当页面从第三方 CDN 引入 jQuery、Bootstrap 时,CDN 被入侵或 DNS 被劫持都会导致恶意代码注入。SRI 用哈希钉死文件内容:

<script src="https://cdn.example.com/lib/jquery-3.7.1.min.js"
        integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
        crossorigin="anonymous"></script>

关键细节:

  • integrity 支持 sha256 / sha384 / sha512,多哈希以空格分隔,浏览器命中其一即通过(用于渐进升级算法)。
  • 必须带 crossorigin="anonymous",否则跨域资源因 CORS 失败而无法校验完整性,SRI 直接失效。
  • SRI 只保证"内容没变",不保证"内容安全"。若第三方库本身有漏洞,SRI 帮不上忙,那是 软件供应链安全 的范畴。
  • 一旦文件更新而 integrity 未同步,资源会静默加载失败——因此SRI 必须纳入构建流程自动生成,而不是手写。

一个实用的构建期做法是用打包插件自动注入:

// webpack: 自动为 CDN 外链注入 integrity
new SubresourceIntegrityPlugin({
  hashFuncNames: ['sha384'],
  enabled: process.env.NODE_ENV === 'production',
});

3.1 import maps 与模块完整性

ES Module 时代,<script type="module"> 同样支持 integrity,但要注意 import maps 声明的裸模块无法直接加 integrity,需要通过 integrity 字段在 import map 中声明:

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.example.com/lodash-es/lodash.js"
  },
  "integrity": {
    "https://cdn.example.com/lodash-es/lodash.js": "sha384-..."
  }
}
</script>

支持度仍在推进,生产环境建议优先自托管关键依赖。

3.2 用命令行核验 SRI 哈希

哈希算错会让资源静默加载失败,因此上线前务必用与浏览器一致的算法核验:

# 生成 sha384 的 integrity 值(base64,不带前缀)
openssl dgst -sha384 -binary jquery-3.7.1.min.js | openssl base64 -A
# 期望输出形如:ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2

# 校验线上返回的字节是否与本地一致
curl -s https://cdn.example.com/lib/jquery-3.7.1.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

若两者不一致,说明 CDN 做了压缩改写(如 gzip 之外的转码、注入 banner),此时要么换源,要么把改写内容一并纳入哈希计算。

四、Trusted Types 与根除 DOM XSS

CSP 拦得住"注入的 <script>",却拦不住 element.innerHTML = userInput 这类把字符串当 HTML 解析的操作——后者产生的 DOM 结构变化不会被 script-src 拦截。这正是 DOM-based XSS 的温床。

Trusted Types 的思路是从类型系统层面禁止把裸字符串塞进危险接收点:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-policy default

开启后,innerHTML、outerHTML、insertAdjacentHTML、eval、script.src、setTimeout(字符串形式)等接收点只接受 TrustedHTML / TrustedScript / TrustedScriptURL 对象,裸字符串会直接抛 TypeError。

净化必须集中在一个受审计的策略里:

// 全局唯一策略,所有 HTML 净化都走这里
const sanitizer = trustedTypes.createPolicy('app-policy', {
  createHTML: (dirty) =>
    DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br'] }),
  createScriptURL: (url) => {
    const allowed = new URL(url, location.origin);
    if (allowed.origin !== location.origin) throw new Error('blocked');
    return allowed.href;
  },
});

落地建议:

  • 用 require-trusted-types-for 的 Report-Only 模式先跑,收集所有违规接收点。
  • 收敛策略数量,一个应用一个策略最易审计;trusted-types 指令里显式列出允许的策略名,未列出的策略创建会被拒绝。
  • 绝大多数场景"净化后赋值"就够,不要为了省事直接 createHTML: s => s——那等于把类型系统绕过去。
  • 与 XSS 与 CSRF 防御 的输入输出编码配合使用,形成"编码 + 类型约束 + CSP"三层。

4.1 渐进迁移与违规采集

真实项目里 DOM 接收点动辄上百处,一步到位不现实。推荐三阶段推进:

  1. 仅报告:require-trusted-types-for 'script' 配合 Content-Security-Policy-Report-Only,让所有违规接收点在控制台与上报端点里暴露出来。
  2. 建默认策略:注册一个 default 策略,把净化逻辑集中进去;trusted-types default 允许它被创建,其余策略名一律拒绝。
  3. 收紧策略:逐模块替换裸 innerHTML 赋值,最终删除 default,只保留经过审计的具名策略。
// 阶段 1:默认策略只记录不拦截,便于统计存量违规
trustedTypes.createPolicy('default', {
  createHTML: (input, sink, type) => {
    console.warn('[TT violation]', { sink, type, sample: input.slice(0, 80) });
    return DOMPurify.sanitize(input);
  },
});

createHTML 的第二个参数 sink 会告诉你违规发生在哪个属性/方法上,是定位历史代码的利器。注意:default 策略一旦存在,未显式指定策略的 innerHTML = 也会走它,因此它必须包含真实净化,而不能是直通函数。

五、跨源隔离与权限收敛

除了防注入,浏览器还提供一批响应头用于收紧跨源交互与能力暴露。

5.1 COOP / COEP / CORP

这三者共同构成跨源隔离(Cross-Origin Isolation),也是使用 SharedArrayBuffer、高精度计时器(performance.now() 亚毫秒精度)的前提。

add_header Cross-Origin-Opener-Policy: same-origin;
add_header Cross-Origin-Embedder-Policy: require-corp;
add_header Cross-Origin-Resource-Policy: same-origin;
头作用常见取值
Cross-Origin-Opener-Policy隔离 window.opener 引用same-origin
Cross-Origin-Embedder-Policy要求所有子资源显式授权嵌入require-corp / credentialless
Cross-Origin-Resource-Policy声明资源可被谁嵌入same-origin / same-site / cross-origin

开启 COEP 后,所有跨域图片、脚本必须带 Cross-Origin-Resource-Policy: cross-origin 或走 CORS,否则加载失败。这是一次性的迁移成本,但换来的是对 Spectre 类旁路攻击的硬隔离。

5.2 Permissions-Policy 与 Referrer-Policy

add_header Permissions-Policy: "geolocation=(), camera=(), microphone=(), payment=(self)";
add_header Referrer-Policy: strict-origin-when-cross-origin;

Permissions-Policy 关闭不需要的浏览器能力,即使页面被注入脚本也无法调用摄像头、定位。Referrer-Policy 避免把带敏感参数的 URL 完整泄露给第三方。

  • 会话 Cookie 一律 HttpOnly + Secure + SameSite=Lax/Strict,敏感场景上 __Host- 前缀。
  • 不要把令牌放进 localStorage——任何 XSS 都能读取。需要客户端持有时优先内存 + 短时效。
  • 缓存头与安全头要一起考虑,否则可能出现 Web 缓存投毒 让攻击者把恶意响应缓存给所有用户。

六、测试与验证

安全头最怕"配置漂移"——某次发版把中间件顺序改了,CSP 就悄悄退化。把断言写进自动化测试:

// Playwright:对关键页面断言安全头
import { test, expect } from '@playwright/test';

const pages = ['/', '/login', '/checkout'];

for (const path of pages) {
  test(`security headers on ${path}`, async ({ request }) => {
    const res = await request.get(path);
    const csp = res.headers()['content-security-policy'] || '';
    expect(csp).toContain("default-src 'none'");
    expect(csp).toContain("object-src 'none'");
    expect(csp).not.toContain("'unsafe-inline'");
    expect(csp).not.toContain("'unsafe-eval'");
    expect(res.headers()['x-content-type-options']).toBe('nosniff');
    expect(res.headers()['strict-transport-security']).toContain('max-age=');
  });
}

再配合一个违规报告监控:把 /csp-report 的采样率打到可观测平台,当 blocked-uri 出现非白名单域名或报告量突增时告警,往往能在攻击者之前发现被注入的脚本。

另外建议对关键页面跑一次 securityheaders.com 或 Mozilla Observatory 的基线评分,把分数变化纳入发版门禁——分数突然从 A 掉到 C,通常意味着某个响应头被移除或放宽。

七、落地检查清单

把上述机制固化成部署前的核对项:

  1. 强制 CSP,含 default-src 'none'、script-src 'nonce-…' 'strict-dynamic'、object-src 'none'、base-uri 'none'、frame-ancestors 'none';先 Report-Only 观察两周。
  2. SRI 全覆盖:所有第三方脚本与样式由构建期注入 integrity 与 crossorigin,并纳入软件供应链治理流程。
  3. Trusted Types 在关键页面开启,净化策略集中且经过代码审计。
  4. 跨源隔离头(COOP/COEP/CORP)按需开启,需用 SharedArrayBuffer 的页面必开。
  5. 权限与引用头默认最严,按需放开。
  6. 回归测试:用 Playwright 等工具断言响应头存在,并在 CI 中对 CSP 报告做阈值告警,防止回退。

客户端安全没有银弹,但它有一个明确的方向:让浏览器默认拒绝一切未经显式授权的执行。CSP 管执行、SRI 管内容、Trusted Types 管数据流、隔离头管边界,四者叠加才能把"注入即执行"这条通路彻底堵死。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「安全」更多文章

  1. SOAR 安全编排自动化与响应
  2. 内部威胁与 UEBA 用户行为分析
  3. 模糊测试与安全测试自动化