写这篇东西的起因,是我被SharedArrayBuffer is not defined这个报错结结实实卡了一个下午。那是一个用 WebAssembly 做多线程计算的工程,代码拆到了 Worker,共享内存的分配也写得规规矩矩,结果浏览器就是不认账。当时我第一反应是构建工具配置出了问题,把 Vite、Webpack、Worker 加载方式翻了个底朝天,最后才发现,问题的根子根本不在代码里,而在两个 HTTP 响应头:Cross-Origin-Opener-Policy和Cross-Origin-Embedder-Policy。这篇就把跨域隔离怎么配置、为什么会这样设计、以及上线后会踩到哪些坑,一次说清楚。
1. 浏览器为什么把 SharedArrayBuffer 这把“大枪”收进了保险柜
1.1 一条报错,把我引到了“跨源隔离”这个名字上
先还原一下当时的场景。我写了一个SharedArrayBuffer(1024 * 1024),打算在多个 Worker 之间共享一个 1MB 的内存块,用来做并行计算的数据交换。代码在本地开发环境跑得好好的,但部署到测试环境后,控制台直接给我来了一句:
SharedArrayBuffer is not defined注意,不是“不可用”,是“未定义”。这种报错最迷惑人的地方在于,它看起来像是一个普通的 JS 变量找不到的错误,但SharedArrayBuffer明明是全局构造函数。我还试过在 Console 里直接输入typeof SharedArrayBuffer,在普通页面返回undefined,在已经开启隔离的浏览器页面里返回function。
更让人崩溃的是报错上的表现差异:Chrome 会直接给你“not defined”,Firefox 有时会给你更绕的提示,提到“requires cross-origin isolation”,Safari 则可能在不同版本里表现还不一样。如果你只盯着代码层排查,哪怕你把 Worker 线程模型重构一百遍,也解决不了这个问题。
所以,这是一条“平台环境”给出的报错,不是“代码逻辑”报错。要搞懂它,必须先了解浏览器在担心什么。
1.2 Spectre 事件之后,浏览器做的取舍
事情要从 2018 年的 Spectre 漏洞说起。那不只是浏览器的问题,而是整个 CPU 微架构层面的侧信道攻击。攻击者可以利用高精度计时器,配合内存时序差异,去猜测进程地址空间里那些本不该被访问的数据。
而SharedArrayBuffer恰好是“高精度计时器”的理想加速器。不夸张地讲,两个线程共享同一块内存,一个线程不断写值,另一个线程用极高的时间精度去观测某个内存位置的读取耗时,就可以把微小的时序差异放大成可测量的信号,这就是侧信道攻击的经典构造。
当年的浏览器厂商没有选择把所有性能 API 全部砍掉,而是做了一个“有条件开放”的决策:
- 普通页面里,
SharedArrayBuffer默认不再可用。 performance.now()的计时精度也被故意降低,防止用来做精确测量。- 如果你愿意接受一套更严格的隔离策略,浏览器就可以把这两样能力还给你。
这套更严格的隔离策略,就是今天说的“跨源隔离”,英文标准名称是 Cross-Origin Isolation,国内技术社区也叫“跨域隔离”。它不是一个单一的 API 开关,而是由一组 HTTP 响应头构成的安全边界,浏览器确认这组边界成立后,才会重新开放SharedArrayBuffer。
1.3 为什么不能用 polyfill 解决
我在排查时也搜到过“polyfill SharedArrayBuffer”的帖子。这里必须泼盆冷水:SharedArrayBuffer被禁用是引擎层面的条件编译,不是这个 API 本身缺失,所以任何 JavaScript 层面的 shim 都补不出“真共享内存”。
你当然能用ArrayBuffer加postMessage把数据复制到 Worker,然后每次计算完再把结果从 Worker 复制回来。但这就失去了“共享”的意义:所有数据都要走结构化克隆,每次传递都是复制开销,大块内存的并发修改更是无从谈起。
所以,想在多线程场景里拿到真正的共享内存,唯一正式的路径就是:让页面进入跨源隔离状态。这也是本文后面所有内容的前提。请记住这句话:跨源隔离不是某一种框架的功能开关,它是页面级的安全状态。
2. 跨域隔离的完整拼图:COOP 与 COEP 缺一不可
跨源隔离由两个 HTTP 响应头组成,分别是:
Cross-Origin-Opener-Policy,通常缩写为 COOP。Cross-Origin-Embedder-Policy,通常缩写为 COEP。
这两个头必须同时生效,页面才会被浏览器认定为“隔离状态”。只配一个,另一个亮红灯,self.crossOriginIsolated依然会是false。下面拆开讲。
2.1 COOP:确保顶层窗口之间“不串门”
Cross-Origin-Opener-Policy控制的是“打开者”和“被打开窗口”之间的关系。默认情况下,当 A 页面通过window.open打开 B 页面时,B 页面可以通过window.opener拿到 A 页面的引用,A 页面也可以通过返回值拿到 B 页面的引用。即使跨源情况下,这个引用关系也依然存在。
这看起来无害,但浏览器在设计隔离方案时,要求页面不能和跨源窗口共享同一个“浏览上下文组”。因为一旦两个窗口在同一个上下文组里,进程级别的隔离就可能被削弱,恶意页面就有机会通过窗口引用去间接探测另一个页面的信息。
当 COOP 的值设置为same-origin时,页面告诉浏览器:我愿意只和同源窗口建立 opener 关系。任何跨源窗口打开我这个页面,或者我这个页面打开跨源窗口,浏览器都会把这两个窗口放进不同的浏览上下文组。最直观的表现就是,window.opener在很多场景下会变成null。
COOP 还有另外两个值,简单理解:
| COOP 值 | 效果 |
|---|---|
unsafe-none | 默认值,不做额外的窗口隔离 |
same-origin | 和同源窗口保持 opener 关系,和跨源窗口切断 |
same-origin-allow-popups | 自身保持隔离,但允许通过 popup 打开的跨源窗口不强制隔离 |
从安全收益看,COOP 能让浏览器更放心地把页面放到一个隔离的进程组里,从而降低跨窗口侧信道攻击的风险。
2.2 COEP:给每个外来资源发“准入证”
如果说 COOP 是管“窗口之间”,那Cross-Origin-Embedder-Policy就是管“页面内部的资源加载”。
当 COEP 设置为require-corp时,页面加载的任何子资源,只要不是同源资源,都必须明确声明自己允许被当前页面嵌入。这种声明有两种常见方式:
- 资源响应头里带上 CORS 头,比如
Access-Control-Allow-Origin: *或匹配当前来源。 - 资源响应头里带上 CORP 头,比如
Cross-Origin-Resource-Policy: cross-origin。
这里比较反直觉的一点是,很多开发者以为“图片天然能跨域加载”。确实,在普通页面里,<img>标签加载一张人家的图片,不需要 CORS,也不需要 CORP,浏览器也不拦。但一旦开了COEP: require-corp,这条“人情世故”就失效了,所有跨源资源都必须证明“我愿意被你嵌入”。
你可以把 COEP 想象成体育馆门口的安检:以前只要长得像观众就能进,现在每个座位都必须对应一张实名票。这张票就是资源自身的响应头。
2.3 两个头一起上,crossOriginIsolated 才会亮灯
为什么两个头缺一不可?因为它们分别堵住了两个维度的攻击路径。
COOP 解决的是“窗口间跨源共享”的问题,COEP 解决的是“页面内容被跨源数据污染”的问题。前者保证了你的内存空间不会被旁边的恶意窗口“串门”读取,后者保证了渲染进程里不会混入无法追溯来源的外部资源。两个条件都满足,浏览器才认为当前页面的内存隔离边界是可信的,才肯把SharedArrayBuffer和更精确的performance.now()还给你。
所以,正确的配置姿势是这样的:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp配置完成后,你可以在页面控制台运行:
self.crossOriginIsolated如果返回true,说明隔离生效,此时new SharedArrayBuffer(...)就应该能正常使用了。
3. 服务器端配置实操:Nginx、Node 与 CDN 的落地方法
理论部分讲完,下面是真正动手的部分。先强调一点:这两个头必须由服务器返回“HTML 文档的响应”时带上,不能只加在一些静态资源请求上。浏览器是根据顶层文档响应头来判断页面是否隔离的。
3.1 Nginx:最常规的配置及其隐藏的 add_header 大坑
如果你的站点用 Nginx 托管,可以在server或location块中配置两个头:
server { listen 443 ssl; server_name app.example.com; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; # 其他配置... }注意always参数。它表示即使请求出现 4xx 或 5xx 错误,响应头也会返回。很多线上事故就是因为错误页面没带头,导致某些回调场景中隔离状态闪断。
这里必须说一个 Nginx 的经典坑:add_header指令不是简单的“叠加”关系,如果在某个location块里写了add_header,它会把上一层server块里的所有add_header全部覆盖掉。看这个例子:
server { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; location /assets/ { add_header Cross-Origin-Resource-Policy "cross-origin" always; # 这里 COOP 和 COEP 头不会生效! } }/assets/这个 location 里,前两个头会被第三个add_header覆盖,结果页面文档请求里只剩下Cross-Origin-Resource-Policy,跨源隔离直接失效。我见过不止一次线上问题出自这个配置细节。如果你要在某个 location 里加额外的头,必须把所有头重新列全:
location /assets/ { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "cross-origin" always; }3.2 Node/Express、Koa 的中间件配置
Express 项目可以在入口处加一个全局中间件:
const express = require('express'); const app = express(); app.use((req, res, next) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); next(); }); // 路由和静态资源...如果是纯 Node 的http服务,同理:
const http = require('http'); const server = http.createServer((req, res) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); // 后续业务处理 });Koa 也差不多,写一个async中间件,设置完头之后await next()就行。
这里有个细节:如果页面路由既有服务端渲染的 HTML,也有静态资源,建议在中间件最外层统一设置,避免遗漏。特别是那些纯静态托管的站点,如果用了无服务函数或者边缘计算,别忘了在那些入口函数里也加上同样的响应头。
3.3 静态资源与 CDN 的 CORP/CORS 头怎么配
页面响应头配好之后,真正的麻烦才刚开始:你页面里所有跨源资源都必须“证明自己愿意被嵌入”。这里的资源包括图片、字体、脚本、样式表、音视频,甚至 iframe 里的文档。
最省事的做法是给所有静态资源统一加上:
Cross-Origin-Resource-Policy: cross-origin这个头告诉浏览器:我这个资源允许任何来源的页面嵌入。如果你的资源都放在同源域名下,其实 COEP 对同源资源是放行的,不需要额外加。但如果你有独立的 CDN 域名、OSS 桶、子域静态资源,那么这些响应头必须跟着资源走。
在 Nginx 里,可以这样给静态资源目录加:
location /static/ { add_header Cross-Origin-Resource-Policy "cross-origin" always; }但注意 3.1 里说的覆盖问题,如果你在这个 location 里也需要 COOP/COEP 头,一定记得补全。
如果你用的是阿里云 OSS、腾讯云 COS、AWS S3 这类对象存储,通常可以在存储桶的“HTTP 响应头”配置里加入 CORP 头。CDN 产品一般也支持“回源 HTTP 头”或“响应头自定义”。
3.4 开发环境:Vite、Next.js、webpack-dev-server
跨源隔离不能在开发环境里缺席,否则你会在本地调试得好好的,上了测试环境才发现资源全被拦了。开发环境配置方式各有不同。
Vite 在vite.config.js中配置:
export default { server: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, };Next.js 在next.config.js中配置:
module.exports = { async headers() { return [ { source: '/(.*)', headers: [ { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' }, ], }, ]; }, };webpack-dev-server 在devServer.headers中配置:
devServer: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }开发环境配置好后,打开浏览器开发者工具,跑一下self.crossOriginIsolated,如果为true,再继续后续开发。
4. 启用隔离后必踩的坑:第三方资源与排查全链路
网上很多教程到“加两个头”就结束了,但真实的痛苦从加完头才开始。下面每一个问题,我都在实际项目里遇到过。
4.1 完整排查链路:从白屏到定位 COEP
上线后如果页面出现图片打不开、视频无法播放、地图控件白屏等问题,先别急着怀疑代码,按这个链路排查:
- 打开 DevTools 的 Network 面板,刷新页面。
- 寻找显示为红色或者带有
blocked标记的请求。 - 点击对应请求,查看右侧 Headers。
- 找到浏览器给出的拦截原因,通常会有
cross-origin-embedder-policy字样。 - 检查这个请求的响应头里,有没有
Access-Control-Allow-Origin或Cross-Origin-Resource-Policy。 - 如果没有,说明这是一个“未申请准入资格”的跨源资源。
- 确认资源归属:是自己的资源就加 CORP/CORS 头;是第三方资源就看对方是否提供配置入口;都没有,就需要另想办法。
控制台里也常出现类似这样含义的报错:
Refused to load ... because it violates Cross-Origin-Embedder-Policy遇到这种报错,完全可以判定是 COEP 拦的。
4.2 第三方图片、字体、脚本、iframe 的处理办法
这是最容易大面积翻车的地方。
- 用户头像、富文本图片:如果站点允许用户粘贴图片 URL,这些外部图片基本没有 CORP 头,开了 COEP 后直接全部加载失败。我的处理方案是:图片统一走自己的转存服务,上传时拉到自己的对象存储,加好 CORP 头再输出。这同时也是统一资源管理的机会。
- 字体 CDN:很多站点从 Google Fonts 等公共字体库加载字体,这些字体服务有的会返回 CORS 头,有的不会。如果字体加载失败,页面文字会回退到系统字体,看起来像样式退化。方案是把字体文件下载到自己域名,或者确认字体 CDN 是否允许 CORS。
- 第三方地图、播放器 iframe:
COEP: require-corp对 iframe 资源同样会检查。很多第三方平台的地图、视频、在线文档嵌入并不带 CORP 头。如果业务上必须嵌入这类第三方页面,要么联系对方确认支持,要么做好功能降级。 - 埋点、广告脚本:这是最隐蔽的问题。第三方 SDK 内部经常动态加载更多脚本或图片,它们的服务器如果没有返回 CORP/CORS 头,就会导致计划任务中间失败。你看到的症状往往是“数据不上报”,而不是“页面报错”。
一个相对立竿见影的兜底方案,是把 COEP 临时从require-corp换成credentialless。这个模式下,浏览器会以“不带凭据”的方式请求跨源资源,很多不需要登录态的静态资源可以在不加 CORP 头的情况下正常加载。但要注意两点:一是credentialless的浏览器支持不像require-corp那么古老且广泛,实施前要在目标浏览器矩阵里再确认;二是依赖 Cookie 做身份校验的跨源接口可能会因此失效,不能无脑上。
4.3 影响业务逻辑的隐藏雷区:弹窗登录、跨源协作、Service Worker
COOP 的影响往往比 COEP 更隐蔽,它不表现为“资源加载失败”,而是表现为“JS 逻辑忽然变了”。
最常见的是弹窗登录。很多站点的 OAuth 登录是打开一个第三方授权窗口,登录成功后通过window.opener.postMessage通知原页面。配置 COOP 为same-origin后,如果第三方授权页面的响应头也是same-origin,或者浏览器判定跨源窗口被隔离,window.opener就会变成null,消息发不回来,登录状态不更新,用户疯狂点按钮,页面毫无反应。
这时候可以视情况把首屏的 COOP 改为same-origin-allow-popups,它会放松对“通过 popup 打开的跨源窗口”的限制,让这类业务继续工作。代价是隔离边界没有same-origin那么强,需要业务和安全团队一起评估。
另一个雷区是子域协作。公司内部如果存在aaa.example.com和bbb.example.com互开窗口通信的场景,在配置 COOP 之前可能依赖window.opener直接调用跨域方法。配了same-origin后,这类跨源 opener 会被切断,必须改造成postMessage+BroadcastChannel之类的显式通信。
还有 Service Worker。如果你的页面启用了 Service Worker,并且它拦截了跨源请求后返回自定义响应,注意这些响应同样要满足 COEP 的校验。否则会出现“请求状态 200,但资源就是渲染不出来”的诡异现象。
4.4 上线前一定要做的 curl 检查
配置改完后,不要只在浏览器里点开看一眼就完事。我习惯在发布前对几个关键地址做一次响应头检查:
curl -I https://app.example.com/重点看有没有这两行:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp同时也抽查几个静态资源地址:
curl -I https://static.example.com/images/logo.png确认资源上有Cross-Origin-Resource-Policy或 CORS 头。这一步几乎能避免 80% 的线上回滚事故。
5. 验证、降级与长期维护
5.1 用 self.crossOriginIsolated 做运行时探测
页面代码里应该做能力检测,而不是假设隔离一定生效。最标准的写法是这样:
function isCrossOriginIsolated() { return self.crossOriginIsolated === true; } function createSharedMemory(size) { if (!isCrossOriginIsolated()) { throw new Error('当前页面未启用跨源隔离,无法使用 SharedArrayBuffer'); } return new SharedArrayBuffer(size); }在 Worker 里也一样,self.crossOriginIsolated同样存在。建议在应用入口处把crossOriginIsolated的值上报到监控平台,这样哪天运维回滚了某个 Nginx 配置、或者 CDN 回源把响应头吞了,你能第一时间在监控曲线里看到异常。
5.2 无法隔离时的降级设计
不是所有浏览器和网络环境都支持跨源隔离。遇到不支持的浏览器,应用不能直接崩溃,要做好降级。
如果你的共享内存只是用来加速数据处理,最朴素的做法就是退回“复制式传输”,通过postMessage把ArrayBuffer转移给 Worker,虽然每次都有复制开销,但功能可用。注意语法上要区分共享和非共享内存,别把所有类型的ArrayBuffer统一当成共享内存来修改。
如果你的核心逻辑依赖 WASM 线程,比如用 Rust 编译的wasm-bindgen-rayon,这需要SharedArrayBuffer,没法优雅降级。那就只能在页面上做一个能力检测,提醒用户:当前浏览器或网络环境不支持高性能模式,建议使用 Chrome 或 Edge 访问。这种方法不好看,但至少不会让用户面对一个死循环加载的白屏页面。
5.3 长期维护检查清单
跨源隔离的维护不是一次性工作,每次引入新依赖、调整 CDN、改服务器配置,都可能打破平衡。我通常会留一份清单:
- 新接入任何第三方 SDK 时,先看它是否动态加载跨源资源,确认这些资源是否带 CORP/CORS 头。
- 新增外部图片、音频、视频、字体引用时,默认假设它们会被 COEP 拦截,除非验证过响应头。
- 服务端配置变更后,通过 curl 抽查 HTML 和静态资源响应头。
- 应用启动时上报
self.crossOriginIsolated,纳入监控异常告警。 - 升级浏览器版本后,留意跨源隔离相关 API 有无行为变化。
- 不要在同一个 Nginx location 里只添加
Cross-Origin-Resource-Policy,把 COOP/COEP 一起写全。
这里也列一个常见的排查结果速查表:
| 现象 | 大概率原因 | 解决方向 |
|---|---|---|
SharedArrayBuffer is not defined | 页面未进入跨源隔离状态 | 检查响应头 COOP/COEP 是否同时返回 |
| 图片/字体加载失败 | COEP 拦截跨源资源 | 给资源加 CORP/CORS 头,或转存自托管 |
| 第三方 iframe 白屏 | iframe 页面没有 CORP 头 | 联系对方配置,或替换嵌入方案 |
| OAuth 登录弹窗后无反应 | COOP 导致window.opener为 null | 改为same-origin-allow-popups或调整登录方案 |
| 数据上报缺失 | 第三方脚本被 COEP 拦截 | 代理脚本或改用自建上报逻辑 |
| 本地正常、线上异常 | 开发环境和生产环境响应头不一致 | 在 dev server 和线上入口都配置同样的头 |
结尾
最后再说一点个人体会。做跨源隔离改造,表面上是加两个响应头,实际上是一次“资源治理”的复盘:你的网站上到底跑了哪些外部资源?它们各自是谁在管理?有没有哪些资源是你完全无法控制的?这些问题在普通页面下基本不会被注意到,因为浏览器对跨源加载足够宽容,但开 COEP 之后,所有“不守规矩”的请求都会用加载失败的方式逼你正视它们。我在改造一个协同编辑项目时,最头疼的其实是用户头像那一堆外链图片,最后干脆全部缓存到自己的 OSS 并统一加上了 CORP 头,页面加载反而又快了一点。每次新接入第三方 SDK,我都会先curl一下它的脚本和图片响应头,吃过一次亏之后,这个习惯就再没丢过。