适用场景
PostMessage 跨域消息安全加固针对的是 window.postMessage 接口被滥用导致的跨域数据泄露与消息伪造。现代 Web 应用大量依赖该接口完成父页面与 iframe、宿主 App 与内嵌 H5、支付页与收银台之间的通信。一旦发送端使用通配符 *、接收端不校验 event.origin,攻击者只需把目标页面嵌入自己的站点,或向目标页面发送伪造消息,就能读取会话数据、触发敏感操作。以下场景必须做专项加固:
- 页面通过 iframe 内嵌第三方内容,或自身被第三方 iframe 内嵌;
- WebView 宿主(App / 小程序容器)通过
postMessage向 H5 下发 token 或用户信息; - 监控、统计、客服、支付类组件通过跨域消息回传数据。
前置条件
- 具备前端源码检索能力,能定位全部
postMessage调用点与message事件监听器; - 服务端可配置响应头(Nginx 或应用层均可),用于下发 CSP 与 X-Frame-Options;
- 浏览器为 Chrome 或 Edge,具备 DevTools 与 Network 面板;
- 已梳理出需要通信的全部可信源(origin)白名单。
原理说明
postMessage 是浏览器提供的跨域通信原语,其签名包含两个关键参数:目标是哪个窗口、以及允许哪些源接收。第一个人为风险点是发送端把 targetOrigin 写成 *,此时消息会投递给任意当前承载该 iframe 的页面,攻击者把受害页面嵌入自己的站点即可直接读取 payload 中的 token、手机号、订单信息。
第二个风险点是接收端不校验 event.origin。任何页面都能通过 iframe.contentWindow.postMessage() 或 window.open 拿到句柄后向目标页面发送伪造指令,例如 {"action":"withdraw","amount":10000}。浏览器不会替开发者做来源鉴权,event.origin 必须由业务代码自行比对。
第三个风险点是只校验 origin 而不校验来源窗口。当同源下存在多个 iframe 时,任意一个同源 iframe 都能冒充合法发送方,因此还需比对 event.source。
操作步骤
步骤一:收敛发送端 targetOrigin
// 错误写法:任何源都能收到 payload
targetWindow.postMessage({ token: userToken, uid: uid }, '*');
// 正确写法:显式指定可信源,且与当前环境严格对应
const TRUSTED_ORIGIN = 'https://app.example.com';
targetWindow.postMessage({ type: 'auth', token: userToken }, TRUSTED_ORIGIN);
// 生产与测试环境分离,避免测试域被用于收包
const ORIGIN_MAP = {
production: 'https://app.example.com',
staging: 'https://staging.example.com',
};
const allowOrigin = ORIGIN_MAP[process.env.APP_ENV];
targetWindow.postMessage(payload, allowOrigin);
若确实需要向动态域发消息,也必须先与后端换取允许列表,禁止在运行时从 URL 参数读取 origin 并直接使用。
步骤二:接收端三重校验
const ALLOW_ORIGINS = ['https://app.example.com', 'https://pay.example.com'];
const ALLOW_SOURCES = new WeakSet();
// 注册 iframe 时记录合法窗口句柄
const iframe = document.getElementById('payFrame');
iframe.addEventListener('load', () => ALLOW_SOURCES.add(iframe.contentWindow));
function isPlainObject(v) {
return Object.prototype.toString.call(v) === '[object Object]';
}
window.addEventListener('message', (event) => {
// 校验 1:来源必须命中白名单,禁止用 indexOf / startsWith 做模糊匹配
if (!ALLOW_ORIGINS.includes(event.origin)) return;
// 校验 2:发送窗口必须是已注册的可信句柄
if (!ALLOW_SOURCES.has(event.source)) return;
// 校验 3:消息结构与字段类型严格校验,拒绝原型污染
const data = event.data;
if (!isPlainObject(data) || typeof data.type !== 'string') return;
if (!['paySuccess', 'payCancel'].includes(data.type)) return;
handle(data);
}, false);
三点必须同时满足:白名单用 includes 精确匹配(startsWith 会被 https://app.example.com.evil.com 绕过)、event.source 用 WeakSet 比对、数据结构做类型断言。event.data 反序列化后可能带有 __proto__ 键,直接用展开运算符合并会污染原型链。
步骤三:禁止在消息中回传高价值凭据
// 不推荐:把 access_token 通过消息通道下发
parent.postMessage({ accessToken: token }, ORIGIN);
// 推荐:只下发一次性、短时效、用途绑定的票据
parent.postMessage({
type: 'authTicket',
ticket: oneTimeTicket, // 后端签发,60 秒有效,仅可使用一次
expiresAt: Date.now() + 60000,
}, ORIGIN);
票据由后端签发并绑定 origin 与 purpose,即便被窃取也无法用于其他场景,这是降低跨域消息泄露影响的根本手段。
步骤四:限制页面被嵌套的能力
# Nginx 下发响应头,禁止被任意站点嵌套
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; frame-ancestors 'self' https://app.example.com;" always;
add_header X-Frame-Options "SAMEORIGIN" always;
# 内嵌第三方 iframe 时使用 sandbox,最小化其能力
# 注意:不要同时给出 allow-scripts 与 allow-same-origin
# <iframe src="https://pay.example.com/checkout"
# sandbox="allow-scripts allow-forms"
# referrerpolicy="no-referrer"></iframe>
frame-ancestors 是阻断「把受害页面嵌入攻击者站点」这一攻击路径的第一道闸门,配置后即使发送端误用 *,攻击页面也无法承载目标 iframe。
步骤五:全量检索并修复存量调用点
# 检索前端源码中全部 postMessage 调用
grep -rn --include=*.js --include=*.ts --include=*.vue --include=*.jsx \
-E "postMessage\s*\(" src/ | grep -v node_modules
# 挑出仍然使用通配符的危险调用
grep -rn --include=*.js --include=*.vue -E "postMessage\([^)]*,\s*['\"]\*['\"]" src/
# 检索全部消息监听器,确认是否都有 origin 校验
grep -rn --include=*.js --include=*.vue -A6 "addEventListener\(['\"]message['\"]" src/ | \
grep -c "event.origin"
最后一条命令统计监听器中 origin 校验的出现次数,与监听器总数比对,即可量化出未加固的调用点。
配置验证
# 1. 确认 frame-ancestors 已生效
curl -sI https://app.example.com/ | grep -i "content-security-policy\|x-frame-options"
# 2. 在攻击者页面中尝试嵌套,浏览器控制台应报 CSP 拦截
# Refused to frame 'https://app.example.com/' because an ancestor violates
# the following Content Security Policy directive: "frame-ancestors 'self'"
# 3. 从不同 origin 发送伪造消息,业务侧不应产生任何动作
// 在 https://evil.example 的控制台执行:
document.getElementById('targetFrame').contentWindow.postMessage(
{ type: 'paySuccess', amount: 99999 }, '*'
);
// 预期:目标页面无任何状态变化
# 4. 精确匹配边界测试:确认 startsWith 类写法已被移除
// 在 https://app.example.com.evil.com 中发送消息
// 预期:因白名单精确匹配失败而被丢弃
# 5. 运行时统计未校验来源的监听器
window.__msgAudit = [];
const _add = window.addEventListener;
window.addEventListener = function (t, fn, o) {
if (t === 'message') window.__msgAudit.push(String(fn).includes('origin'));
return _add.call(this, t, fn, o);
};
验证通过的标准是:跨域嵌套被 CSP 拦截、跨源伪造消息不触发业务逻辑、白名单边界测试全部被拒绝。
常见问题
FAQ 1:接收端已经校验了 event.origin,为什么还能被利用?
最常见的原因是校验写法不严谨。典型错误有三种:一是用 event.origin.indexOf('example.com') !== -1,攻击者用 https://example.com.evil.com 即可命中;二是用 startsWith 做前缀匹配,同样会被 example.com.attacker.net 绕过;三是校验 event.origin 却忽略了同源下多个 iframe 可以互相冒充,缺少 event.source 比对。修复方式是统一改为 ALLOW_ORIGINS.includes(event.origin) 的精确数组匹配,并同时注册可信窗口句柄做二次校验。
FAQ 2:只在内网使用的页面,是否还需要做 origin 校验?
需要。内网页面同样会出现在用户浏览器的上下文中,用户访问任意公网页面时,该页面都能通过 iframe 加载内网地址(浏览器不限制页面嵌套内网资源,只受同源策略约束),随后向内网 iframe 发送伪造消息。加之浏览器对 postMessage 的来源不做任何限制,内网边界并不能提供保护。判定是否需要加固的唯一标准是页面是否使用了 postMessage 或是否可能被第三方包裹,而不是它部署在哪里。
FAQ 3:sandbox 属性加了 allow-scripts 之后,页面还能正常通信吗?
可以通信,但有限制。被 sandbox="allow-scripts" 限制的 iframe 会获得唯一的源(opaque origin),其 event.origin 值为 null,此时基于 origin 白名单的校验会全部失败。正确的处理方式有两种:一是给目标 iframe 使用独立子域并配置 allow-scripts allow-same-origin,让它以真实源参与通信(前提是子域与主站隔离、无敏感 Cookie);二是保留 opaque origin,改用后端签发的一次性票据做身份绑定,不依赖 origin 判断。切忌为了「让通信能用」而盲目同时开启 allow-scripts 与 allow-same-origin,这会直接抵消 sandbox 的隔离价值。
总结
PostMessage 跨域消息安全加固的核心是三件事:发送端把通配符 * 全部替换为精确的 targetOrigin;接收端同时校验 event.origin 精确白名单与 event.source 可信句柄,并对 event.data 做结构断言;服务端用 frame-ancestors 与 X-Frame-Options 切断被第三方嵌套的路径。三者叠加后,即使单个页面存在疏漏,攻击者也无法同时满足「承载目标页面」与「伪造可信来源」两个条件。存量项目应先用 grep 量化出全部调用点与缺失校验的监听器数量,再按风险从高到低逐个整改。