浏览器把自己关进了沙箱:Spectre、跨源隔离与那 95 微秒

前阵子调一个 WebAssembly 多线程的东西,一上来就撞死了:

ReferenceError: SharedArrayBuffer is not defined

同一个文件摆在 localhost 上就正常,扔到生产环境就报这个。查了一圈才发现,差别不在代码,而在两个响应头。

顺着这两个头查下去,我把整个交易看懂了。先看一个最直观的证据——在这样一个几乎空白的页面上:

let prev = performance.now()
const values = new Set()
const deltas = new Set()
for (let i = 0; i < 200000; i++) {
  const n = performance.now()
  values.add(n)
  const d = n - prev
  if (d > 0) deltas.add(d)
  prev = n
}

二十万次调用,我拿到了这样两个结果:

没加响应头      {"distinctValues":203,  "smallestDeltasMs":[0.0999999, 0.1000000, 0.1999999, ...]}
加了两个响应头  {"distinctValues":3960, "smallestDeltasMs":[0.0049999, 0.0050001, 0.0099999, ...]}

203 个不同的值,3960 个不同的值。中间差的,就是这篇文章要讲的东西。

一块被装进透明玻璃保险箱里的浏览器窗口,窗口在箱子里泛着淡蓝的光,蓝光映在玻璃上,深色背景的产品渲染图

下面所有数字都是我在 Chrome for Testing 149.0.7827.55(Linux x86_64,navigator.hardwareConcurrency 是 12)上真跑出来的,脚本可以照着复现。

# 一、它防的到底是谁

故事要从 2018 年 1 月说起。Spectre 和 Meltdown 被公开之后,整个行业发现一件很难堪的事:CPU 的推测执行会执行本不该执行的分支,而且即使 CPU 事后把这个结果丢掉了,它在缓存里留下的痕迹不会被丢掉。

于是只要同时满足两个条件,攻击就成立:

  1. 我能在同一台机器上跑自己的代码(在浏览器里,一个 <script> 就够了)
  2. 我有一把足够精细的秒表,能把缓存里"这个位置被调用过"这件事读出来

条件 1 浏览器管不了。同源策略从来只承诺"不让你读数据",从来没承诺过"不让你和我并排坐进同一块内存"。

所以能动的只有条件 2,而浏览器里恰好有两样现成的东西:performance.now() 负责提供时间,SharedArrayBuffer 提供一块能被反复冲刷的共享内存。两个凑在一起,就是一套标准的缓存侧信道测量工具。

浏览器厂商的第一反应很粗暴:2018 年,Chrome 和 Firefox 直接把 SharedArrayBuffer 从页面里摘掉了(后来在站点隔离的保护下短暂放回过一段时间)。到 2021 年,Chrome 92 起换了个思路,改成一笔交易:

想用这些危险的高精度能力?可以。但你得先把整个页面关进一个笼子——笼子里不许有任何没明确"自报家门"的跨源资源,也不许和跨源的窗口有任何关系。

这个笼子叫跨源隔离(Cross-Origin Isolation),两道锁分别叫 COOP 和 COEP。

# 二、实验台:两个 origin,六组响应头

规则不在代码里,全在响应头里,所以实验台得我自己搭。两个 origin:

  • 主站 http://localhost:8200(扮演我的网页)
  • 第三方 http://localhost:8201(扮演 CDN、字体站、广告、嵌入的三方页面)

localhost 虽然是明文 HTTP,但它是 trustworthy origin,同样算 secure context,所以不用配证书就能验证。主站这边按路径给不同的响应头:

const MAIN_HEADERS = {
  '/plain': {},
  '/coop': { 'Cross-Origin-Opener-Policy': 'same-origin' },
  '/coep': { 'Cross-Origin-Embedder-Policy': 'require-corp' },
  '/both': {
    'Cross-Origin-Opener-Policy': 'same-origin',
    'Cross-Origin-Embedder-Policy': 'require-corp'
  },
  '/cred': {
    'Cross-Origin-Opener-Policy': 'same-origin',
    'Cross-Origin-Embedder-Policy': 'credentialless'
  },
  '/coop-popups': {
    'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
    'Cross-Origin-Embedder-Policy': 'require-corp'
  }
}

六个页面挨个打开,量四样东西:

响应头 crossOriginIsolated SharedArrayBuffer performance.now 最小步长 measureUserAgentSpecificMemory
什么都不加 false undefined 100 µs 不存在
只有 COOP: same-origin false undefined 100 µs 不存在
只有 COEP: require-corp false undefined 100 µs 不存在
COOP + COEP require-corp true function 5 µs 可用
COOP + COEP credentialless true function 5 µs 可用
same-origin-allow-popups + COEP false undefined 100 µs 不存在

几个必须记住的结论:

  • 两个头缺一不可。 只加一个,能力一个都解锁不了。
  • COOP: same-origin-allow-popups 不算数(最后一行是实测值)。这条指令本意是"我和我打开的弹窗之间的关系别切断",但它换不来隔离。做 OAuth 弹窗登录、支付回调窗口的人会卡在这里——因为 same-origin 会让它们的 window.opener 变成 null(实测:普通页面打开同源弹窗后 w.opener !== null,COOP: same-origin 下变成 null,same-origin-allow-popups 下又是好的)。
  • 除了这两个头,还得是 secure context(HTTPS 或 localhost),并且没有被 Permissions-Policy 里的 cross-origin-isolated 指令禁掉,三样齐了 crossOriginIsolated 才是 true。别猜,运行时读 window.crossOriginIsolated 就知道了。

浏览器支持这边:COOP/COEP 是 Chrome 83、Firefox 79、Safari 15.2 才有的;credentialless 是 Chrome 96 才加的,到今天也只有 Chromium 系支持。

# 三、拿回来的第一样东西:一把细 20 倍的尺子

开头那两个数字,说的就是这件事。

未隔离时 Chrome 把 performance.now() 的粒度钳到 100 微秒,并且每个值都落在 100µs 的整数格上——所以二十万次调用只吐出 203 个不同的值,大约每 1000 次调用才往前挪一格。隔离之后粒度变成 5 微秒,同样二十万次调用吐出 3960 个值,分辨率提高了 20 倍。

秒表表盘的特写,看得见微秒级的细密刻度,一根泛着蓝光的指针停在两个最小刻度之间,微距摄影

这 95 微秒的差价到底意味着什么?单个缓存命中与否的时间在几十到上百纳秒的量级——所以 5 微秒的尺子依然量不出"一次访问",但这不重要:侧信道攻击从来不是只测一次,它测几千几万次然后做统计。100µs 的尺子把信号抹平了,5µs 的尺子抹不平。

这也是为什么这个能力不能随便给:它是被明文绑定在一个"我先把陌生人全请出去"的承诺上的。

# 四、第二样东西:真正的共享内存

先确认它是真的共享,不是"看起来像":

const sab = new SharedArrayBuffer(16)
const view = new Int32Array(sab)
worker.postMessage({ buf: sab, slot: 0, n: 1000 }) // worker 里做 1000 次 Atomics.add
await done
console.log(view[0]) // 1000  <-- 主线程没有收到任何消息,值自己变了

view[0] 直接是 1000。worker 没有 postMessage 回来,主线程也没有 .then()——同一块物理内存,改完就看见了。

顺手踩到一个老坑:

Atomics.wait(view, 0, 0, 1)
// TypeError: Atomics.wait cannot be called in this context

Atomics.wait 在主线程永远是禁止的(它会把线程卡死),什么条件下都一样。Atomics.waitAsync 则两种环境里都能用。

# 到底能有多快

四百万次 Atomics.add(x, slot, 1),换四种排布:

排布 耗时 每秒操作数
1 个 worker,全做完 28.0 ms 1.43 亿
4 个 worker,各自写相距 1KB 的槽位 13.9 ms 2.89 亿
4 个 worker,写相邻的槽位(slot = i) 59.6 ms 0.67 亿
4 个 worker,抢同一个槽位 93.6 ms 0.43 亿

第二行和第三行的差距值得单独说:四个 worker 写的是不同的变量,但因为它们挨得太近,落在同一条 cache line 上,CPU 的缓存一致性协议会把这四次写入当成互相打架。 这就是伪共享(false sharing)——4 个人干活比 1 个人干活还慢一倍。

真实代码里这意味着:用 SharedArrayBuffer 做计数器聚合的时候,别让每个 worker 的计数器在内存里肩并肩站着,给它们 padding 到 64 字节以上。

四个发光的工人把手同时伸向中央那块共享的内存方块,数据线连接在彼此之间,深色科技感背景的抽象三维插画

# 但它不是万能药:postMessage 其实没那么慢

顺便量了一下老办法。一个 worker 和主线程一来一回,做十万次:

roundTrips: 100000, totalMs: 667.8, usPerRoundTrip: 6.678

单次往返 6.678 微秒,也就是每秒大约 15 万次。回头看上面那张表:4 个 worker 伪共享那一行花了 59.6 毫秒——同样的时间里,postMessage 大概能做 8900 次往返。

所以这笔账真正的分水岭不是"快不快",而是"传多少、传几次"。 一次来回 6.7µs 对绝大多数应用来说完全够用;只有当你的数据量大到结构化克隆的成本成为瓶颈、或者调用次数多到百万级的时候,共享内存才真正开始划算。

# 五、第三样东西:看清自己吃了多少内存

隔离解锁的第三个 API 是 performance.measureUserAgentSpecificMemory()。在一个空白页面上调用:

{
  "bytes": 432889,
  "breakdown": [
    { "attribution": [], "bytes": 174404, "types": ["Shared"] },
    { "attribution": [], "bytes": 0, "types": [] },
    { "attribution": [], "bytes": 24064, "types": ["DOM"] },
    {
      "attribution": [
        { "scope": "Window", "url": "http://localhost:8200/both" }
      ],
      "bytes": 234421,
      "types": ["JavaScript"]
    }
  ]
}

一个几乎什么都没干的页面,43 万字节。其中 JS 堆 23 万,DOM 2.4 万,还有 17 万是浏览器自己那份共享基础设施。attribution 为空的那几条,是归因不到你这个 JS realm 头上的浏览器内部开销。

这个数字每次跑都不一样(JIT 和 GC 状态不同),别拿绝对值当事实。有用的是它的结构:这是唯一一个能在生产环境里、按你自己世界的边界,把内存拆开给前端看的 API。想知道 SPA 跑一晚上涨了多少、某个路由是不是泄漏,以前只能猜,现在能测。

代价:目前只有 Chromium 系支持,而且必须隔离。

# 六、账单来了:第三方资源

前面三样是收货,从这里开始是付款。

我在第三方 origin 上放了五种资源,然后挨个模式去加载它们:

主站响应头 普通图片(无 CORP) 带 CORP: cross-origin 有 CORS 但不写 crossorigin 有 CORS 且写了 crossorigin 跨源 iframe
无 / 只有 COOP ✅ ✅ ✅ ✅ ✅
COEP: require-corp(含 COOP 组合) ❌ ✅ ❌ ✅ ❌
COEP: credentialless ✅ ✅ ✅ ✅ ❌

被打回去的时候,控制台是这样一句:

Failed to load resource: net::ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep

后半句是重点:COEP 给没有 CORP 头的资源,默认填了一个 same-origin。 也就是说,你没显式表态,浏览器就替你选了最严的那个。

于是教训很具体:

  • 图片、字体、脚本:要么对方响应带上 Cross-Origin-Resource-Policy: cross-origin,要么你走 CORS 并且在标签上老老实实写 crossorigin="anonymous"(第三列证明:只有 CORS 头、不写属性,一样跪——这一条最容易在字体上翻车)
  • iframe:单独一节,见下
  • <link rel=preload>、<script>、fetch():全部适用,没有例外
  • 自己的 Service Worker 也绕不过去:我注册了一个监听 /proxyimg 的 worker,它内部 fetch('http://localhost:8201/nocorp.png', { mode: 'no-cors' }) 后再把响应吐出去。普通页面 OK 1px;同一份代码在 COOP + COEP: require-corp 的页面上,结果是 BLOCKED。别指望用它当代理偷偷绕开这笔帐。

堆满集装箱的霓虹海关闸口,一部分箱子亮着红光被拦下,唯一一个亮着绿光正在通过,夜晚的赛博朋克港口

如果你既拿不到第三方资源的 CORP 头,也拿不到 CORS 头,只剩一条路:把它代理到你自己的同源下——注意是服务端代理,不是 Service Worker(SW 那条路刚才已经被实测堵死了)。多一跳,换来不用说服任何人。

# 七、iframe 是最贵的那一刀

第六节最后那列看起来只是"也被拦了",但 iframe 的通行条件和图片根本不是一回事。我试了四种响应头组合(主站都是 COOP: same-origin + COEP: require-corp):

iframe 文档的响应头 结果
什么都不加 ❌ 被拦
CORP: cross-origin ❌ 被拦
CORP + COOP: same-origin ❌ 被拦
CORP + COEP: require-corp ✅ 通过
什么都不加,但写 <iframe credentialless> ✅ 通过

第一条最反直觉:给 iframe 加 CORP 头没用。 人家要的是"你自己的 COEP",不是"你允不允许被嵌"。这就是当年 YouTube / Twitter / 各种嵌入式 consumer 组件一开 COEP 就全部消失的原因——那些 origin 你根本联系不上,谈何加 COEP。

Chromium 后来为此专门做了一扇逃生门:anonymous iframe(第四行的 <iframe credentialless> 实测可用,哪怕第三方一个响应头都没加)。它给这个 iframe 一个独立的、没有任何凭证的临时环境(相当于用一个全新的、从未登录过的浏览器打开它),所以浏览器肯让它进笼子。

代价也很清楚:里面的页面拿不到 cookie、storage,登录态全丢。嵌个 YouTube 视频可以,嵌个需要登录的后台 iframe 不行。而且这属性 Chromium 系独有。

# 八、credentialless:要不要凭证,你自己选

require-corp 太硬,于是有了第二种 COEP:credentialless。

它的逻辑简单粗暴:既然没有 CORP 的资源这么麻烦,那我请求的时候干脆不带凭证,这样它拿不到任何跟用户身份有关的东西,也就没什么可泄漏的了——于是 CORP 检查可以放行。

服务端日志是这么写的(我给两个页面种了同一个 cookie):

CDN GET /nocorp.png cookie=probe=abc123     <-- 普通页面发起的请求
CDN GET /nocorp.png cookie=NONE             <-- credentialless 页面发起的请求

一行 cookie=NONE,说完了全部:credentialless 会把跨源 no-cors 请求上的 cookie 全部摘掉。

翻译成人话:

  • CDN 上的图片、字体、静态资源:无所谓,它们本来就不要凭证 → credentialless 是净赚,你一个第三方联系人都不用说服
  • 需要登录态的 API、带认证的 CDN 私有文件:直接失效,且失败得很安静
  • Firefox / Safari 不认这个值 → 你还是得同时备一套 require-corp,按 UA 分支下发两个头

# 九、真要上线,怎么开

顺序很重要,别一上来就全量。

1. 先只报告,不执行。 两个 -Report-Only 版本先铺上去,配套的端点用 Reporting-Endpoints 定义:

Reporting-Endpoints: coop="https://example.com/report", coep="https://example.com/report"
Cross-Origin-Opener-Policy-Report-Only: same-origin; report-to="coop"
Cross-Origin-Embedder-Policy-Report-Only: require-corp; report-to="coep"

这种方式下资源该加载照样加载,只是违规行为会发报告。静默观察一周,看看哪些资源会挂——尤其是那些"只在某个地区的 CDN 上才缺 CORP"的资源。

2. 旧的浏览器 / 不支持的浏览器要有退路。 运行时判断比 UA 嗅探可靠:

if (self.crossOriginIsolated) {
  startSharedMemoryVersion()
} else {
  startPostMessageVersion() // 6.7µs 一次往返,其实够用
}

3. 加上生产环境的两个头。 别忘了 always,它负责让 4xx/5xx 响应也带上:

add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;

如果这个域名前面还挂着 CDN,记住一件事:CDN 缓存响应头的规则要一并改,否则你会遇到"源站有头、边缘节点没头"的鬼故事——页面在部分节点上是隔离的,在另一部分节点上不是,crossOriginIsolated 时真时假。

4. 把靠 opener 传值的窗口通讯改掉。 COOP: same-origin 会让 window.opener === null(实测:普通页面和 same-origin-allow-popups 下 opener 还在,same-origin 下就没了)。OAuth 弹窗、支付回调这些老写法会静默失效,改成 postMessage 或者服务端回跳。

5. 最后再确认一次: window.crossOriginIsolated === true。这是唯一的真相,别信"头应该加上了"。

# 十、这笔交易划不划算

一句话总结我这一天的实验:

跨源隔离不是性能优化,是一笔交换。你交出的是"随便加载别人资源"的自由,换回的是"共享内存 + 5 微秒的时钟 + 看自己吃多少内存"这三个能力。

该开的:

  • WebAssembly 多线程:ffmpeg.wasm、多线程版的 SQLite / DuckDB、图像视频编解码、物理模拟——它们的线程之间必须共享线性内存,postMessage 的克隆成本会让多线程失去意义
  • 需要把计时精度做到微秒级的自监控 / A/B 计时 / 渲染管线测量
  • 长会话应用(编辑器、设计工具、监控大盘)想在生产环境真的盯住自己的内存曲线

不该开的:

  • 内容站、营销页、广告变现页面:第六节那张表每一行 ❌ 都可能是真金白银,而你可能连第三方服务商的电话都打不通
  • 只有一两个 worker、数据量不大的场景:记住那个 6.678 微秒。如果你的瓶颈不在这里,别为了"现代化"去付这笔管理费

对一个完全由自己说了算的工具型应用来说,这笔交易划算得很——毕竟要说服的人只有你自己;对一个往上贴了 20 个第三方 script 的站点来说,它可能就是"打开两个响应头,网站废一半"的开始。

至于我开头撞到的那个 ReferenceError:加上两个响应头之后它消失了,我那台 Linux 机器上的 12 个核第一次在同一个页面里真·共享起了同一块内存。这个交换我付得起,因为我那个页面从头到尾只加载自己的东西。