跨源通信安全实践:postMessage 的 origin 校验与消息协议
本文目录
把第三方页面嵌进 iframe、让它们通过 postMessage 互相通信,是支付收银台、SSO 登录、嵌入式编辑器、广告与埋点SDK 的标准结构。这套 API 只有几个方法,但安全事件几乎年年出现,模式高度一致:消息没校验来源,或者校验的方式是错的。这篇文章聚焦跨源 iframe 场景(同页多标签页的通信选型是另一回事,见文末参考),把发送端、接收端、协议设计、沙箱配置和典型漏洞模式一次讲清。
postMessage 三参数的真实语义
targetWindow.postMessage(message, targetOrigin, [transfer]);
| 参数 | 真实作用 | 常见误解 |
|---|---|---|
message | 结构化克隆算法序列化的消息体 | 不是字符串专用,可传对象与 Transferable |
targetOrigin | 发送端的闸门:对方窗口 origin 与它不匹配则拒绝发送(消息静默丢弃) | 它不是接收端的过滤器,更不是权限声明;自己这边"想发给谁"由它决定,"谁发给自己"它管不着 |
transfer | 移交所有权的对象(如 ArrayBuffer) | 移交后原引用失效,误传会丢数据 |
targetOrigin 最容易写错的姿势:
// 什么时候都"能发出去",等于放弃了发送端的最后一道校验
iframe.contentWindow.postMessage(payload, '*');
// 明确指名道姓:origin 匹配(协议 + 域名 + 端口全等)才送达
iframe.contentWindow.postMessage(payload, 'https://widget.partner.example.com');
'*' 的合法用途很窄:你确实不知道对方在哪(比如要回消息给 event.source 的动态窗口,或做探测类工具)。业务消息应该写死精确 origin——URL 拼错了它会静默失败(无报错、无回调),排查手段是抓包看 frame 是否真正发送,并在开发环境封装统一的 safePost 打日志。
另一个细节:origin 匹配是完整三元组(scheme/host/port)。https://a.com 与 https://a.com:8443、http://a.com 与 https://a.com 互不相等;IP 与域名也互不相等。
接收端:origin 白名单校验是安全的地基
监听 message 事件的任何代码,都在接收任意页面发来的数据——不只你嵌的 iframe,也包括其他打开的窗口、甚至被注入的脚本所在页面。所以接收端第一行代码永远是校验 event.origin:
const TRUSTED_ORIGINS = new Set([
'https://app.example.com',
'https://editor.example.com',
]);
window.addEventListener('message', (event: MessageEvent) => {
// 1. 来源白名单:三元组精确匹配,禁止前缀/后缀/正则模糊匹配
if (!TRUSTED_ORIGINS.has(event.origin)) {
console.warn(`rejected message from ${event.origin}`);
return;
}
// 2. 消息结构校验:type 是必须字段,payload 再按 type 校验
if (typeof event.data !== 'object' || event.data === null) return;
if (typeof event.data.type !== 'string') return;
// 3. 校验通过后才进业务分发
handle(event as MessageEvent<WidgetMessage>);
});
三条纪律,每条都对应真实漏洞:
| 纪律 | 反面案例模式 |
|---|---|
白名单用 Set 精确匹配 | 用 event.origin.endsWith('example.com') —— 攻击者注册 evil-example.com 或 example.com.evil.io 直接绕过 |
校验 event.origin,不要校验 event.data 里自带的"来源字段" | 消息体里的 from: 'trusted-app' 是发送方可任意伪造的,校验它等于不校验 |
| 不同消息通道用不同 listener,别用一个 handler 接所有窗口 | 大杂烩 handler 里漏掉一条分支的校验,整个通道全开 |
还有一点容易被忽略:event.source(发消息的 window 引用)可以配合校验——重要消息要求"来源 origin 在白名单且 source 是我创建的那个 iframe 的 contentWindow"。双因子校验能挡住"白名单域名下的其他窗口"发来的越权消息。
结构化克隆:能传什么,不能传什么
postMessage 的序列化用的是结构化克隆算法,边界要背下来:
| 类型 | 结果 |
|---|---|
| 普通对象、数组、Typed Array、Map/Set、Date、Blob/File | 可传,结构化克隆 |
| 函数 | 抛 DataCloneError,整条消息发不出去 |
| DOM 节点 | 抛 DataCloneError |
| class 实例 | 数据被克隆,原型链与方法丢失——对方拿到的是"形状相同的普通对象" |
| 循环引用 | 支持传(算法处理了),但对方拿到的是克隆图 |
Proxy 包装对象 | 多数行为异常或报错,不要传 |
实践推论:消息协议只用纯数据(POJO),跨窗口不传"对象",只传"数据"。class 的方法丢失经常在联调后期才暴露:发送端 msg.isValid() 好好的,接收端报 isValid is not a function。正确做法是传 type + 序列化数据,行为逻辑留在各自窗口内。
大文件类载荷(如上传的 ArrayBuffer)加进 transfer 列表可零拷贝,但移交后原窗口不能再用——两边谁负责"持有"要在协议里写死。
消息协议设计:type、关联 id 与 ack
裸发字符串在 demo 里能用,生产环境必须定义协议。最小可靠协议有四个字段:
{
"v": 1,
"type": "setHeight",
"id": "req-20261022-0001",
"payload": { "height": 320 },
"ackFor": null
}
字段职责:
| 字段 | 职责 | 不做会怎样 |
|---|---|---|
v | 协议版本号 | 两端异步升级后老消息被新代码"误解",线上无法回滚 |
type | 消息类型,接收端唯一分发依据 | 消息没有命名空间,第三方脚本注入一条 {...} 就能触发你的逻辑 |
id | 请求关联:每次请求生成唯一 id,响应对话带回同一 id | 异步响应无法与请求配对,乱序后张冠李戴 |
ackFor | 确认:处理完的回执指向原请求 id | 消息丢失(窗口销毁、origin 不匹配静默丢弃)无法感知,只能超时重试或永久丢 |
配套实现的样子:
class CrossOriginRpc {
private pending = new Map<string, { resolve: (v: unknown) => void; timer: number }>();
private nextId = 0;
constructor(
private readonly remote: Window,
private readonly remoteOrigin: string,
private readonly timeoutMs = 5000,
) {
window.addEventListener('message', (event) => {
if (event.origin !== this.remoteOrigin) return; // 双向都要校验
const msg = event.data as WidgetMessage;
if (msg.type === 'error') { /* 终态:记录并通知 */ return; }
const waiter = msg.ackFor && this.pending.get(msg.ackFor);
if (waiter) {
window.clearTimeout(waiter.timer);
this.pending.delete(msg.ackFor);
waiter.resolve(msg.payload);
}
});
}
request<TReq, TRes>(type: string, payload: TReq): Promise<TRes> {
const id = `req-${Date.now()}-${this.nextId++}`;
return new Promise<TRes>((resolve, reject) => {
const timer = window.setTimeout(
() => { this.pending.delete(id); reject(new Error(`ack timeout: ${type}`)); },
this.timeoutMs,
);
this.pending.set(id, { resolve: resolve as (v: unknown) => void, timer });
this.remote.postMessage({ v: 1, type, id, payload } as WidgetMessage, this.remoteOrigin);
});
}
}
再补三条协议纪律:
- 未知的
type必须忽略并打日志,不能当成错误抛——协议演进期新老共存是常态,抛错会把整个 handler 搞挂。 - 请求要幂等或可重试:跨窗口消息没有投递保证,超时重试前先想清楚"重复执行两次"会不会出问题(比如重复扣款类操作必须幂等键)。
- 载荷字段有大小上限意识:超大对象结构化克隆阻塞主线程(MB 级可能卡几十 ms),能分块就分块。
双向信任:嵌入方与被嵌入方的各自责任
跨源嵌入是双向的信任问题,两边都可能是受害者:
嵌入方(宿主页面)的风险:嵌入的第三方页面被攻破(供应链事故)或本来就想搞事。防线:
- 给 iframe 加
sandbox,只授予必要的令牌(见下文配置表)。 - 宿主自身不接收"未预期来源"的消息:即使嵌了 widget,
messagehandler 仍然只认白名单 origin——嵌了不等于全部信任。 - 敏感数据不主动发给 iframe:需要用户在 iframe 内完成的操作(支付、登录),把授权做成"iframe 告诉宿主'用户已完成,去取结果',宿主用自己后端的会话取结果",而不是把 token 塞进 postMessage。
被嵌入方(widget)的风险:任何站点都能 <iframe> 你的页面,伪装成你的宿主,诱骗用户在里面交互(点击劫持变体)。防线:
- 服务端下发 CSP
frame-ancestors,声明允许被哪些站点嵌: - 消息 handler 校验的 origin 是"宿主白名单",与宿主校验"widget 白名单"是两份配置,不要混用同一个列表(允许嵌你的站,不等于你的消息要无条件回它)。
Content-Security-Policy: frame-ancestors 'self' https://app.example.com;
Content-Security-Policy: frame-src https://widget.partner.example.com;
第一条是 widget 方下发(管"谁能嵌我"),第二条是宿主下发(管"我能嵌谁")。注意 frame-ancestors 不在 <meta> 里生效(必须是响应头,或用 X-Frame-Options 做旧浏览器回退——但 X-Frame-Options 只能全禁或同源,表达不了白名单)。
sandbox 令牌:最小权限
sandbox 属性的默认值是"全锁死"(无脚本、无表单、无同源),加令牌才解锁能力,加什么都要有理由:
| 令牌 | 解锁能力 | 建议 |
|---|---|---|
allow-scripts | 可执行脚本 | 几乎必然要给,但与 allow-same-origin 组合时要极其谨慎(见下) |
allow-same-origin | iframe 内容保留其 origin(否则被塞进 opaque origin,不能读自己的 Cookie/storage) | 同源 iframe 同时给这两个令牌 = 沙箱失效(脚本可拆掉沙箱属性),跨源 iframe 组合也削弱隔离,逐案评审 |
allow-forms | 可提交表单 | 支付/登录场景才给 |
allow-popups | 可 window.open | 默认不给,防弹窗滥用 |
allow-top-navigation | 可导航顶层页面 | 默认不给;给了要配合协议白名单 |
典型组合:sandbox="allow-scripts"(能跑能通信、不能碰 Cookie 与顶层导航)是嵌入不可信内容时的默认起点,按缺的功能逐项加令牌,并在评审时问"这个令牌被滥用会怎样"。
window.open 弹窗通信
弹窗场景与 iframe 同源校验逻辑一致,但生命周期更麻烦:
// 宿主:打开并等对方就绪后再通信(对方页面加载完成前 postMessage 会丢)
const popup = window.open('https://sso.example.com/login', 'ssologin',
'width=480,height=640');
// 若被浏览器拦截,popup 为 null —— 必须在用户点击的直接调用栈里 open
popup?.postMessage({ v: 1, type: 'handshake' }, 'https://sso.example.com');
// 弹窗侧:回消息给开窗者,同样带精确 origin
window.opener?.postMessage({ v: 1, type: 'loginResult', id, payload: token },
'https://app.example.com');
弹窗特有的坑:
- 就绪问题:弹窗文档没加载完就
postMessage会静默丢;标准解法是"子窗口加载后主动发handshake,宿主收到再发业务消息",而不是宿主盲发。 - 窗口随时会死:
popup.closed轮询或收不到 ack 就提示用户;用户手动关掉弹窗不会给你发任何通知。 window.opener可能是null(直接打开、跳转过中转页),逻辑上当成"没爹"处理而不是崩溃。- 回退通道:如果弹窗被浏览器策略拦截(无用户手势),提供"同窗口跳转 + 回跳后从后端取结果"的降级,别把核心流程全押在弹窗上。
常见漏洞案例模式
四类高频模式,自查时按这个清单过:
- 不校验 origin。handler 直接
JSON.parse(event.data)进业务。后果:诱导用户打开的任意恶意页面(甚至同一站点的 XSS 页面)都能往 handler 发消息,控制宿主。这是 postMessage 漏洞的头号形态。 - 通配符
'*'发敏感数据。回消息给event.source时顺手postMessage(token, '*')——此刻你不知道接收方是谁,token 可能被第三方 iframe 接走。回消息也必须写精确 origin。 - 信任消息体里的身份声明。校验
event.data.from === 'partner'而不校验event.origin,伪造成本为零。 - 宽匹配的"白名单"。
includes('example.com')、正则里未锚定的^https?://、把路径写进 origin 比较里(origin 不含路径,带路径比较永远失败,容易诱导改成模糊匹配)。
审计口诀:每个 message listener 回答两个问题——消息从哪个 origin 来被允许?未知 type 走了哪条路径? 答不上来就是风险点。
注意事项与边界
event.origin是浏览器保证的、不可伪造(除非来源站自身被 XSS 控制了脚本——校验 origin 防的是"别的页面冒充",防不了"白名单站点自己被攻破",后者要靠最小授权与沙箱兜底)。- postMessage 没有顺序与投递保证之外的事务语义:没有"恰好一次",只有"至少一次 + 幂等",重复消息去重靠协议里的 id。
- iframe 的 origin 会因为重定向变化:打开时是 A 站、登录后 302 到 B 站,按"窗口"记的白名单会失效,校验只认
event.origin实时值。 - 通信是双向各自独立的:A 允许收 B 的消息,不代表 B 允许收 A 的;两边都要各自配白名单与协议版本。
- 第三方 SDK 的"postMessage 集成"先问清协议版本与回执机制,再接生产:没有 ack 与版本号的集成,出了问题双方都看不到。
- 单元测试要覆盖"恶意 origin 消息被拒"用例:用 iframe 指向测试域名的页面发消息,断言 handler 不产生副作用——这类安全分支恰是最缺测试的地方。
总结
跨源通信安全的完整结构是三层叠加:发送端用精确 targetOrigin 把消息送达限定在预期窗口;接收端用 Set 白名单精确校验 event.origin 加消息结构校验,这是不可省略的地基;协议层用 v/type/id/ack 四字段让演进可控、可确认、可重试;再以 frame-ancestors 与 sandbox 最小权限兜底,防住"白名单站点自身出事"的连带。每一层的共同原则:信任必须显式声明、精确匹配、双向独立——通配符与模糊匹配出现的每一个位置,都是一个等待被利用的漏洞。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。