PostMessage跨域通信:安全的陷阱

作者:Yolo 发布时间: 2026-06-19 阅读量:1

在现代 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, FileList

  • ArrayBuffer, ImageData, ImageBitmap

  • MessageChannelMessagePort


不支持:

  • 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, '*');

攻击场景:

  1. 用户访问恶意网站 evil.com
  2. evil.com 通过 window.open() 打开目标网站 victim.com
  3. evil.com 监听 message 事件
  4. 如果 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,攻击者仍可能通过窗口引用劫持进行攻击。

攻击场景:

  1. 页面 A(a.com)嵌入 iframe B(b.com
  2. 页面 A 同时通过 window.open() 打开了恶意页面 C(evil.com
  3. 恶意页面 C 可以导航到 b.com(通过 window.location
  4. 如果页面 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;
    }
});

攻击场景:

  1. partner.com 存在存储型 XSS 漏洞
  2. 攻击者在 partner.com 注入恶意脚本
  3. 恶意脚本通过 postMessage 向父页面发送 payload
  4. 父页面将 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
原型链污染使用安全的对象创建和解析方式

核心原则:

  1. 发送时:明确指定 targetOrigin,绝不使用 '*'
  2. 接收时:验证 event.origin,验证 event.source,验证数据格式
  3. 处理时:将 event.data 视为不可信输入,进行适当的过滤和转义
  4. 架构上:考虑使用 MessageChannel 替代简单的 postMessage
记住:postMessage 解决了跨域通信的问题,但没有解决信任问题。 在跨域通信中,始终假设对方可能是恶意的,除非你能严格验证它的身份。
系列文章导航: