服务端再严,最终把 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 Types | DOM 接收点 | 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-src | fetch/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 接收点动辄上百处,一步到位不现实。推荐三阶段推进:
- 仅报告:
require-trusted-types-for 'script'配合Content-Security-Policy-Report-Only,让所有违规接收点在控制台与上报端点里暴露出来。 - 建默认策略:注册一个
default策略,把净化逻辑集中进去;trusted-types default允许它被创建,其余策略名一律拒绝。 - 收紧策略:逐模块替换裸
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 完整泄露给第三方。
5.3 客户端存储与 Cookie
- 会话 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,通常意味着某个响应头被移除或放宽。
七、落地检查清单
把上述机制固化成部署前的核对项:
- 强制 CSP,含
default-src 'none'、script-src 'nonce-…' 'strict-dynamic'、object-src 'none'、base-uri 'none'、frame-ancestors 'none';先 Report-Only 观察两周。 - SRI 全覆盖:所有第三方脚本与样式由构建期注入
integrity与crossorigin,并纳入软件供应链治理流程。 - Trusted Types 在关键页面开启,净化策略集中且经过代码审计。
- 跨源隔离头(COOP/COEP/CORP)按需开启,需用
SharedArrayBuffer的页面必开。 - 权限与引用头默认最严,按需放开。
- 回归测试:用 Playwright 等工具断言响应头存在,并在 CI 中对 CSP 报告做阈值告警,防止回退。
客户端安全没有银弹,但它有一个明确的方向:让浏览器默认拒绝一切未经显式授权的执行。CSP 管执行、SRI 管内容、Trusted Types 管数据流、隔离头管边界,四者叠加才能把"注入即执行"这条通路彻底堵死。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。