
写这篇东西的起因是我被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。第三方地图、播放器 iframeCOEP: require-corp对 iframe 资源同样会检查。很多第三方平台的地图、视频、在线文档嵌入并不带 CORP 头。如果业务上必须嵌入这类第三方页面要么联系对方确认支持要么做好功能降级。埋点、广告脚本这是最隐蔽的问题。第三方 SDK 内部经常动态加载更多脚本或图片它们的服务器如果没有返回 CORP/CORS 头就会导致计划任务中间失败。你看到的症状往往是“数据不上报”而不是“页面报错”。一个相对立竿见影的兜底方案是把 COEP 临时从require-corp换成credentialless。这个模式下浏览器会以“不带凭据”的方式请求跨源资源很多不需要登录态的静态资源可以在不加 CORP 头的情况下正常加载。但要注意两点一是credentialless的浏览器支持不像require-corp那么古老且广泛实施前要在目标浏览器矩阵里再确认二是依赖 Cookie 做身份校验的跨源接口可能会因此失效不能无脑上。4.3 影响业务逻辑的隐藏雷区弹窗登录、跨源协作、Service WorkerCOOP 的影响往往比 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 会被切断必须改造成postMessageBroadcastChannel之类的显式通信。还有 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一下它的脚本和图片响应头吃过一次亏之后这个习惯就再没丢过。