在现代 Web 应用中,跨域通信是一个无法回避的需求。不同域名、不同窗口、不同 iframe 之间如何安全地交换数据?HTML5 引入的 postMessage API 为这个问题提供了原生的解决方案。然而,这个看似简单的 API 背后,隐藏着许多开发者容易忽视的安全陷阱。本文将深入剖析 postMessage 的工作原理、常见误用场景以及防御策略。
一、为什么需要 postMessage
同源策略的限制
浏览器的同源策略(Same-Origin Policy)是 Web 安全的基石。它限制了一个源(origin)的文档或脚本如何与另一个源的资源进行交互。两个 URL 属于同源,当且仅当它们的协议、域名和端口都相同。
http://example.com/page1 → http://example.com/page2 ✅ 同源
http://example.com/page1 → https://example.com/page2 ❌ 协议不同
http://a.example.com → http://b.example.com ❌ 域名不同
http://example.com:80 → http://example.com:8080 ❌ 端口不同同源策略阻止了不同源之间的直接 DOM 访问、Cookie 读取、AJAX 请求等操作。但在实际业务中,我们经常需要:
- 嵌入第三方 iframe(支付、地图、社交插件)
- 实现单点登录(SSO)的跨域通信
- 微前端架构中不同子应用之间的状态同步
- 弹出窗口(popup)与父窗口的数据交换
postMessage 的诞生
在 postMessage 出现之前,开发者们使用了各种hack 手段来实现跨域通信:
| 方法 | 原理 | 缺点 |
|---|---|---|
| URL hash | 通过修改 location.hash 传递数据 | 数据量受限,需要轮询 |
| window.name | 利用 window.name 跨域持久化 | 只能传递字符串,安全性差 |
| JSONP | 通过 <script> 标签绕过同源策略 | 只支持 GET,存在 XSS 风险 |
| 服务器代理 | 通过后端转发请求 | 增加服务器负担,延迟高 |
postMessage 提供了一种标准化、安全的跨域通信机制:
// 发送消息
targetWindow.postMessage(message, targetOrigin);
// 接收消息
window.addEventListener('message', function(event) {
// 处理消息
});二、postMessage API 详解
基本语法
// 发送端
targetWindow.postMessage(message, targetOrigin, [transfer]);参数说明:
message:要发送的数据。可以是任何可序列化的对象(会被结构化克隆算法处理)targetOrigin:目标窗口的源。必须是完整的 URL 或"*"(不推荐)transfer(可选):Transferable 对象数组,发送后所有权转移
// 接收端
window.addEventListener('message', function(event) {
// event 对象包含以下重要属性:
event.origin // 发送方的源(协议+域名+端口)
event.source // 发送消息的窗口引用
event.data // 接收到的数据
event.ports // MessagePort 数组(用于通道通信)
});简单示例
父页面 → iframe:
<!-- parent.html -->
<iframe id="child" src="https://child.example.com/"></iframe>
<script>
const iframe = document.getElementById('child');
// 等待 iframe 加载完成
iframe.onload = function() {
// 向 iframe 发送消息
iframe.contentWindow.postMessage(
{ action: 'login', token: 'abc123' },
'https://child.example.com/' // 明确指定目标源!
);
};
// 监听 iframe 的回复
window.addEventListener('message', function(event) {
// 必须验证消息来源!
if (event.origin !== 'https://child.example.com/') return;
console.log('收到子页面消息:', event.data);
});
</script>iframe → 父页面:
// child.example.com 中的代码
window.addEventListener('message', function(event) {
// 验证父页面来源
if (event.origin !== 'https://parent.example.com/') return;
const data = event.data;
if (data.action === 'login') {
// 处理登录逻辑
localStorage.setItem('token', data.token);
// 回复父页面
event.source.postMessage(
{ status: 'success', user: 'admin' },
event.origin // 使用 event.origin 确保回复到正确的源
);
}
});结构化克隆算法
postMessage 使用结构化克隆算法(Structured Clone Algorithm)序列化数据,支持的数据类型包括:
✅ 支持:
- 基本类型:
string,number,boolean,null,undefined - 对象和数组(循环引用也支持)
Date,RegExp,Map,Set,Blob,File,FileListArrayBuffer,ImageData,ImageBitmapMessageChannel和MessagePort
❌ 不支持:
Function(函数会丢失)DOM 节点- 某些属性的对象(如
Error对象的stack)
// 可以发送复杂对象
const data = {
user: { id: 1, name: 'Alice' },
permissions: new Set(['read', 'write']),
metadata: new Map([['key', 'value']]),
createdAt: new Date(),
buffer: new ArrayBuffer(1024)
};
window.postMessage(data, '*');三、安全陷阱与攻击场景
陷阱 1:使用 "*" 作为 targetOrigin
危险代码:
// ❌ 极度危险!消息会发送到任意窗口
window.parent.postMessage(secretData, '*');攻击场景:
- 用户访问恶意网站
evil.com evil.com通过window.open()打开目标网站victim.comevil.com监听message事件- 如果
victim.com使用'*'发送敏感数据,evil.com就能截获
// evil.com 的攻击代码
const victim = window.open('https://victim.com');
window.addEventListener('message', function(event) {
// 如果 victim.com 用 '*' 发送消息,这里能收到!
console.log('截获到数据:', event.data);
// 可能包含 session token、用户个人信息等
});正确做法:
// ✅ 始终明确指定目标源
window.parent.postMessage(secretData, 'https://parent.example.com/');陷阱 2:不验证 event.origin
危险代码:
// ❌ 接收任何来源的消息
window.addEventListener('message', function(event) {
// 没有验证 event.origin!
const data = event.data;
if (data.action === 'transfer') {
// 执行转账操作
executeTransfer(data.amount, data.to);
}
});攻击场景:
// 攻击者在任意页面执行
window.open('https://bank.com').postMessage({
action: 'transfer',
amount: 100000,
to: 'attacker_account'
}, '*');如果银行网站没有验证 event.origin,就会执行转账操作!
正确做法:
// ✅ 白名单验证来源
const ALLOWED_ORIGINS = [
'https://trusted-partner.com',
'https://sso.example.com'
];
window.addEventListener('message', function(event) {
if (!ALLOWED_ORIGINS.includes(event.origin)) {
console.warn('拒绝来自未知来源的消息:', event.origin);
return;
}
// 继续处理消息...
});陷阱 3:不验证 event.source
即使验证了 origin,攻击者仍可能通过窗口引用劫持进行攻击。
攻击场景:
- 页面 A(
a.com)嵌入 iframe B(b.com) - 页面 A 同时通过
window.open()打开了恶意页面 C(evil.com) - 恶意页面 C 可以导航到
b.com(通过window.location) - 如果页面 B 使用
event.source.postMessage()回复,消息可能发送到错误的地方
// ✅ 保存预期的窗口引用并验证
const expectedSource = iframe.contentWindow;
window.addEventListener('message', function(event) {
if (event.origin !== 'https://b.com') return;
// 验证消息确实来自预期的窗口
if (event.source !== expectedSource) {
console.warn('消息来源窗口不匹配');
return;
}
// 处理消息
});陷阱 4:XSS 通过 postMessage 传播
如果接收方直接将 event.data 插入 DOM,而发送方被 XSS 攻击,恶意脚本可以通过 postMessage 传播。
危险代码:
// ❌ 直接将消息内容插入 DOM
window.addEventListener('message', function(event) {
if (event.origin === 'https://partner.com') {
document.getElementById('content').innerHTML = event.data.html;
}
});攻击场景:
partner.com存在存储型 XSS 漏洞- 攻击者在
partner.com注入恶意脚本 - 恶意脚本通过
postMessage向父页面发送 payload - 父页面将 payload 插入 DOM,触发 XSS
// ✅ 永远不信任外部数据,使用 textContent 或转义
window.addEventListener('message', function(event) {
if (event.origin !== 'https://partner.com') return;
const data = event.data;
// 验证数据格式
if (typeof data.text !== 'string') return;
// 使用 textContent 而非 innerHTML
document.getElementById('content').textContent = data.text;
});陷阱 5:JSON 解析导致原型链污染
如果接收方使用 JSON.parse() 处理消息,且后续对解析结果进行不安全的操作,可能导致原型链污染。
// 攻击 payload
const payload = {
"__proto__": {
"isAdmin": true
}
};
window.parent.postMessage(JSON.stringify(payload), '*');防御:
// ✅ 使用 Object.create(null) 创建无原型对象
// 或使用 JSON.parse 的 reviver 参数
const data = JSON.parse(event.data, (key, value) => {
if (key === '__proto__') return undefined;
return value;
});四、安全最佳实践
1. 发送方安全清单
// ✅ 始终指定明确的 targetOrigin
window.parent.postMessage(data, 'https://expected-parent.com');
// ❌ 永远不要这样做
window.parent.postMessage(data, '*');
// ✅ 敏感数据只发送到可信源
const trustedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com'
]);
function sendSecureMessage(targetWindow, data, origin) {
if (!trustedOrigins.has(origin)) {
throw new Error('Untrusted origin: ' + origin);
}
targetWindow.postMessage(data, origin);
}2. 接收方安全清单
// ✅ 完整的接收方安全处理
(function() {
'use strict';
// 白名单配置
const ALLOWED_ORIGINS = [
'https://trusted-domain.com',
'https://partner.example.org'
];
// 消息处理器注册表
const handlers = new Map();
// 注册消息处理器
function registerHandler(action, handler) {
handlers.set(action, handler);
}
// 主消息监听
window.addEventListener('message', function(event) {
// 1. 验证来源
if (!ALLOWED_ORIGINS.includes(event.origin)) {
console.warn('[Security] Blocked message from:', event.origin);
return;
}
// 2. 验证数据类型
if (!event.data || typeof event.data !== 'object') {
return;
}
// 3. 验证必要字段
const { action, payload, requestId } = event.data;
if (!action || typeof action !== 'string') {
return;
}
// 4. 查找并执行处理器
const handler = handlers.get(action);
if (!handler) {
console.warn('[Security] Unknown action:', action);
return;
}
// 5. 执行处理(带错误捕获)
try {
const result = handler(payload, event);
// 6. 如果有 requestId,发送回复
if (requestId && event.source) {
event.source.postMessage({
requestId,
status: 'success',
data: result
}, event.origin);
}
} catch (error) {
console.error('[Message Handler Error]', error);
if (requestId && event.source) {
event.source.postMessage({
requestId,
status: 'error',
message: error.message
}, event.origin);
}
}
});
// 暴露注册接口
window.MessageBus = { registerHandler };
})();
// 使用示例
MessageBus.registerHandler('getUserInfo', function(payload, event) {
// 额外的权限检查
if (!isAuthorized(event.origin, 'user:read')) {
throw new Error('Unauthorized');
}
return { id: 1, name: 'User' };
});3. 使用 MessageChannel 进行双向通信
对于需要持续通信的场景,MessageChannel 提供了更安全的双向通道:
// 父页面
const channel = new MessageChannel();
const iframe = document.getElementById('child');
iframe.onload = function() {
// 将 port2 传递给 iframe
iframe.contentWindow.postMessage('init', 'https://child.com', [channel.port2]);
};
// 通过 port1 通信
channel.port1.onmessage = function(event) {
console.log('收到:', event.data);
};
channel.port1.postMessage('Hello from parent');// 子页面
window.addEventListener('message', function(event) {
if (event.origin !== 'https://parent.com') return;
if (event.data !== 'init') return;
// 获取 port
const port = event.ports[0];
port.onmessage = function(e) {
console.log('收到:', e.data);
port.postMessage('Hello from child');
};
});优势:
- 不需要持续验证
event.origin(通道建立时已验证) - 通信双方明确,不会被其他窗口截获
- 更适合复杂的双向通信场景
4. 内容安全策略(CSP)配合
通过 CSP 限制 postMessage 的使用范围:
Content-Security-Policy:
frame-src https://trusted-domain.com https://partner.com;
child-src https://trusted-domain.com;五、实战案例:安全的微前端通信
// 微前端通信总线
class MicroFrontendBus {
constructor(allowedOrigins) {
this.origins = new Set(allowedOrigins);
this.handlers = new Map();
this.pendingRequests = new Map();
this.requestId = 0;
window.addEventListener('message', this.handleMessage.bind(this));
}
handleMessage(event) {
// 严格验证来源
if (!this.origins.has(event.origin)) {
return;
}
const message = event.data;
// 处理回复
if (message.requestId && this.pendingRequests.has(message.requestId)) {
const { resolve, reject } = this.pendingRequests.get(message.requestId);
this.pendingRequests.delete(message.requestId);
if (message.status === 'error') {
reject(new Error(message.message));
} else {
resolve(message.data);
}
return;
}
// 处理请求
const handler = this.handlers.get(message.action);
if (!handler) return;
Promise.resolve(handler(message.payload, event))
.then(result => {
if (message.requestId) {
event.source.postMessage({
requestId: message.requestId,
status: 'success',
data: result
}, event.origin);
}
})
.catch(error => {
if (message.requestId) {
event.source.postMessage({
requestId: message.requestId,
status: 'error',
message: error.message
}, event.origin);
}
});
}
register(action, handler) {
this.handlers.set(action, handler);
}
send(targetWindow, targetOrigin, action, payload) {
return new Promise((resolve, reject) => {
if (!this.origins.has(targetOrigin)) {
reject(new Error('Untrusted origin'));
return;
}
const id = ++this.requestId;
this.pendingRequests.set(id, { resolve, reject });
// 设置超时
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 10000);
targetWindow.postMessage({
action,
payload,
requestId: id
}, targetOrigin);
});
}
}
// 使用
const bus = new MicroFrontendBus([
'https://app1.example.com',
'https://app2.example.com'
]);
// 注册处理器
bus.register('getUser', async () => {
return await fetch('/api/user').then(r => r.json());
});
// 发送请求
const result = await bus.send(
iframe.contentWindow,
'https://app1.example.com',
'getUser',
{ id: 123 }
);六、总结
postMessage 是一个强大但危险的工具。它的安全性完全取决于开发者的使用方式。
| 风险 | 防御措施 |
|---|---|
| 消息被截获 | 始终指定明确的 targetOrigin,不使用 '*' |
| 消息来源伪造 | 严格验证 event.origin,使用白名单 |
| 窗口引用劫持 | 验证 event.source 是否匹配预期 |
| XSS 传播 | 不要直接将 event.data 插入 DOM |
| 原型链污染 | 使用安全的对象创建和解析方式 |
核心原则:
- 发送时:明确指定
targetOrigin,绝不使用'*' - 接收时:验证
event.origin,验证event.source,验证数据格式 - 处理时:将
event.data视为不可信输入,进行适当的过滤和转义 - 架构上:考虑使用
MessageChannel替代简单的postMessage
postMessage 解决了跨域通信的问题,但没有解决信任问题。 在跨域通信中,始终假设对方可能是恶意的,除非你能严格验证它的身份。
系列文章导航:
- 同源策略与CORS:浏览器安全的边界
- CSP内容安全策略:深度防御配置
- WebSocket安全:双向通信的风险
- PostMessage跨域通信:安全的陷阱 ← 本文
- DOM Clobbering:HTML注入的另类攻击(下一篇)