ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

iframe跨域通信实战:postMessage双向消息传递与安全校验全解

iframe跨域通信实战:postMessage双向消息传递与安全校验全解 做iframe嵌入开发这几年我踩过最多的坑基本都集中在“跨域”两个字上。大家平时聊跨域聊得最多的是接口请求被CORS拦但真正做嵌入集成之后会发现更麻烦的是窗口之间的跨域通信——父页面和一个来自其他域名的iframe别说调函数了连读取对方window下的全局变量都会被浏览器直接拒绝。这种场景下能用的正规手段就是postMessage配合message事件。这篇文章是结合我实际做过的报表嵌入、工作台聚合、以及跨系统业务联动项目把iframe跨域消息通信的API细节、父子页面双向通信写法、安全校验和各类线上踩坑一次性讲透适合正在做iframe嵌入、微前端容器、聚合平台或者有跨窗口数据同步需求的前端同学。1. 同源策略挡住了直连通道postMessage成了跨窗口通信的正规军1.1 同源策略到底拦了什么浏览器的同源策略表面上只是“规则”实际上是安全底线。判断同源的标准很简单协议、域名、端口三者全部一致。例如https://work.example.com:8443和https://work.example.com其实都算跨域因为端口不同http://work.example.com和https://work.example.com也算跨域因为协议不同。只要有一项对不上父页面和iframe之间就无法访问彼此的DOM、Cookie、localStorage等。我见过不少刚接触iframe的同事第一反应是“子页面里定义了window.reportData父页面直接取就行了”。结果一跑就报SecurityError。这不是某个浏览器的怪脾气而是所有主流浏览器的一致行为。跨域的两个window原则上只保留极少数可交互入口postMessage就是其中之一。同源策略对不同资源的影响其实分好几层操作类型同源页面跨域页面直接访问对方DOM节点可以禁止直接调用对方window全局函数可以禁止共享Cookie、localStorage可以禁止发起请求并读取响应可以需CORS放开禁止通过postMessage收发消息可以可以表格里最后一行就是postMessage存在的理由。它是浏览器官方提供的“窗口间消息通道”允许两个不同源的window安全地交换数据而且使用起来足够简单语义也清晰。1.2 其他跨域方案为什么不能替代窗口通信既然要解决跨域很多同学第一反应是“我后端配一下CORS不就好了”。但这里要厘清一个概念CORS解决的是HTTP请求层面的跨域和“父页面与iframe之间传递消息”完全是两码事。它可以让你的页面正常调接口但无法让父页面获取iframe内部变量。类似地JSONP也是网络请求层的另一种技巧靠script标签加载回调同样解决不了“窗口给窗口直传消息”的问题。document.domain方案则只适用于同主域下的子域场景比如a.example.com和b.example.com通过都把document.domain设成example.com来互通但粒度太粗开放的是DOM访问权限而且协议或端口不一致时依然失效。还有人用过URL hash或window.name来回传数据这类方案属于纯hack有大小限制、需要轮询监听消息可达性和可靠性都很差。真的要在iframe和父页面之间做稳定、双向、结构化的消息通信postMessage就是最合适的选择它原生支持跨域消息通过事件派发不占额外的网络请求数据格式也支持结构化克隆基本上能把你日常用到的对象类型传过去。2. postMessage的API拆解一次发送与监听接收的完整链路2.1 发送端APIpostMessage(message, targetOrigin, [transfer])postMessage挂在window对象上调用方是“目标窗口”。这句话是理解整个API的钥匙。父页面想把消息发给iframe要取到iframe的contentWindow然后在它身上调用postMessage// 父页面 const iframe document.getElementById(childFrame); iframe.onload function () { iframe.contentWindow.postMessage( { type: THEME_CHANGE, payload: { theme: dark } }, https://child.example.com ); };子页面想把消息发给父页面则在window.parent或window.top上调用// 子页面iframe内部 window.parent.postMessage( { type: CHILD_READY, payload: {} }, https://parent.example.com );第一个参数message可以是任意可结构化克隆的数据。普通对象、数组、字符串都行甚至Map、Set、ArrayBuffer这类结构也可能被序列化传输。但需要注意不能传函数DOM节点也不能直接传结构化克隆算法不支持这些。第二个参数targetOrigin是值得反复强调的。它表示目标窗口的合法源可以传*表示不限定也可以传一个明确的URL前缀或源字符串。它的工作机制是当你调用postMessage时浏览器会检查目标window当前的源是否匹配targetOrigin指定的源不匹配就丢弃消息不产生任何报错。这会导致一个隐蔽的坑——你以为消息发出去了但对方一直收不到原因就是你把页面部署到了另一个域名源不匹配。推荐iframe.contentWindow.postMessage(msg, https://child.example.com) 不推荐iframe.contentWindow.postMessage(msg, *)第三个参数transfer是可选传参用于传递MessagePort或ArrayBuffer等可转移对象。跨窗口通信时配合MessageChannel做更底层的双向通信会用到日常业务开发很少需要了解即可。2.2 接收端API监听message事件发送端只管发接收端必须提前挂好message事件监听器两个条件缺一不可。// 接收端父页面或子页面都一样 window.addEventListener(message, function (event) { // 一定要先校验来源 if (event.origin ! https://child.example.com) return; const msg event.data; switch (msg.type) { case REPORT_SELECT: // 处理业务逻辑 break; default: break; } }, false);message事件回调里拿到的event对象有四个关键属性属性含义使用建议event.data发送方传入的数据必须视为“不可信外部输入”event.origin发送方窗口的源形如https://child.example.com接收业务数据前必校验event.source对发送方window的只读引用可用于回复也可用来校验来源对象event.ports随消息传递的MessagePort数组进阶通信会用到event.source在实践中很有价值。父页面可能嵌入多个iframe每个iframe都会往父页面发消息如果所有消息混在一起处理很难区分是谁发的。这时除了校验origin还可以把event.source和特定iframe的contentWindow做比对const iframeA document.getElementById(frameA); const iframeB document.getElementById(frameB); window.addEventListener(message, function (event) { if (event.origin ! https://child.example.com) return; if (event.source iframeA.contentWindow) { // 来自iframeA的消息 } else if (event.source iframeB.contentWindow) { // 来自iframeB的消息 } }, false);这个写法在大屏页面同时嵌多个子应用时非常有用不至于所有子域名的消息都挤在同一个入口里懵圈。2.3 页面关闭或跳转时消息的去向还有一个容易忽略的点postMessage是异步投递的发送后消息会进入目标window的事件循环不是同步执行。如果接收方页面已经关闭或正在跳转消息就丢了。这个特性让通信必须设计“就绪握手”后面实战部分我会专门展开。3. 父页面与嵌套iframe双向通信实战工作台业务联动一例3.1 场景设定假设我有两个系统主站部署在https://work.example.com另一个报表工具部署在https://report.example.com。主站左侧有导航右侧主体区域嵌入报表工具的iframe。需求是用户在主站切换主题iframe要立即同步换肤用户在iframe里点中某个报表后主站侧边栏要展示该报表的详情主站进入“全屏预览”模式时iframe工具栏要自动隐藏。这是一个典型的双向通信场景两个系统完全独立部署、跨域只能走postMessage。我把两个页面核心逻辑都写出来直接可以抄。3.2 父页面实现发送指令、监听子页面事件// 父页面work.example.com const reportFrame document.getElementById(reportFrame); let reportReady false; // 向子页面发送消息的统一封装 function sendToReport(msg) { if (!reportFrame || !reportFrame.contentWindow) return; reportFrame.contentWindow.postMessage(msg, https://report.example.com); } // 父页面初始化等iframe加载完成后发送配置 reportFrame.addEventListener(load, function () { sendToReport({ type: WORKBENCH_INIT, payload: { theme: dark, defaultReportId: rpt_10086 } }); }); // 父页面监听子页面消息 window.addEventListener(message, function (event) { if (event.origin ! https://report.example.com) return; const msg event.data || {}; switch (msg.type) { case REPORT_READY: // 收到子页面“我准备好了” reportReady true; sendToReport({ type: SET_THEME, payload: { theme: currentTheme } }); break; case REPORT_SELECT: // 用户在子页面选中了报表 renderSidebarDetail(msg.payload); break; case REPORT_ERROR: // 子页面内部异常上报给父页面 toast.error(msg.payload.message); break; default: break; } }, false); // 父页面自行触发的条项主题切换 function changeTheme(theme) { currentTheme theme; // 无论子页面是否已就绪都尝试发送 sendToReport({ type: SET_THEME, payload: { theme } }); }这里有个细节父页面在load事件里发了WORKBENCH_INIT但这个时机只是“iframe的HTML加载完成”并不代表iframe里的JavaScript已经初始化好、监听器已经挂上。所以我在子页面里加了一个REPORT_READY的主动上报父页面收到这个再补发主题配置。这个动作看起来多了一条往返实际能规避绝大多数“首条消息丢失”问题。3.3 子页面实现监听指令、回传事件// 子页面report.example.com const workbenchOrigin https://work.example.com; // 告诉父页面我准备好了 function notifyReady() { window.parent.postMessage( { type: REPORT_READY, payload: { version: 1.2.0 } }, workbenchOrigin ); } // 子页面监听父页面指令 window.addEventListener(message, function (event) { if (event.origin ! workbenchOrigin) return; const msg event.data || {}; switch (msg.type) { case WORKBENCH_INIT: applyInitConfig(msg.payload); break; case SET_THEME: applyTheme(msg.payload.theme); break; case TOGGLE_FULLSCREEN: toolBar.hidden msg.payload.fullscreen ? true : false; break; default: break; } }, false); // 用户点击了报表某一行 function onReportRowClick(reportId) { window.parent.postMessage( { type: REPORT_SELECT, payload: { reportId: reportId, reportName: getReportName(reportId), time: Date.now() } }, workbenchOrigin ); }如果你看过一些postMessage的旧教程里面经常用window.parent.postMessage(data, *)。我在子页面里一律写死父页面源原因后面安全章节会重点讲。简单说就是宁可少发一条业务消息也不要把消息广播给所有同览窗口。3.4 握手协议避免iframe未就绪时的消息丢失很多新手最大的困惑是“监听器已经挂上了为什么收不到消息”。postMessage是异步投递一旦发送方调用后消息进入目标窗口的队列。如果此时目标窗口还没绑定message监听器这条消息就没后续了。拿着上面的例子来说如果我在子页面里做了异步加载报表数据的操作监听器挂载时间被推迟到500ms之后父页面在load事件里立刻发送的WORKBENCH_INIT就会被丢弃。等到子页面恢复父页面的初始化指令已经没了。关于这个问题我在实战中用得最顺手的方案不是“等onload后延时发送”而是显式握手子页面内部初始化完成后主动给父页面发一条REPORT_READY父页面收到REPORT_READY后再把需要下发的配置、主题、默认参数通过SET_THEME、WORKBENCH_INIT等消息批量补发父页面同时维护一个readyTimer比如5秒内没收到REPORT_READY就上报或重试。这套设计思路和TCP握手有点像成本极低却能显著减少“偶发收不到消息”的问题。尤其是子页面依赖接口异步初始化业务数据时这种就绪确认机制几乎是必须的。4. 别把event.data当可信数据三层加固消息安全4.1 第一层发送方把targetOrigin写死不用通配符targetOrigin不只是一个校验机制更是一种边界控制。当它被传成*时消息会被投递到任意同览事件的window包括那些你完全不认识的恶意页面。如果页面里嵌套的iframe被攻击者控制了哪怕它收不到消息数据也可能通过监听message事件获取你暴露的业务细节。所以业务消息一律不要用*应该显式指定目标源// 错误示范 window.parent.postMessage(msg, *); // 正确做法母公司主站源单独维护为常量 const PARENT_ORIGIN https://work.example.com; window.parent.postMessage(msg, PARENT_ORIGIN);有同学会问“如果开发环境和线上环境域名不一样怎么办”这个看起来麻烦但最好是做成配置项构建时按环境注入或者运行时从页面URL里解析出可信源。不建议偷懒用*。4.2 第二层接收方用白名单校验event.origin接收端最大的风险是“来者不拒”。很多代码写了一半发现message事件能触发业务也通了就没加来源校验直接把event.data拿来操作DOM、发请求、掉接口。这种写法非常危险因为任何一个和页面同窗口场景下的跨域iframe都能向这个监听器发消息。伪造消息内容并不难。我的习惯是维护一个白名单数组const TRUSTED_ORIGINS [ https://work.example.com, https://work2.example.com ]; window.addEventListener(message, function (event) { if (!TRUSTED_ORIGINS.includes(event.origin)) return; handleMessage(event.data); }, false);如果页面同时接入多个可信iframe白名单方式比单值判断更方便后续加新域名也只要在数组里加一项。需要注意白名单位置最好放在监听回调的“第一行”一旦来源不匹配直接return后面什么都不做。4.3 第三层统一消息协议让数据自带业务语义和安全上下文就算来源校验通过了event.data仍然是不可信输入。原因很简单即使来源域名是你的合作伙伴也不代表对方业务代码里不会有XSS漏洞或内部人员恶意操作。所以消息本身应该设计成一种“密封信封”而不是把敏感操作直接暴露在裸数据里。我用过的比较顺手的协议格式是这样的{ version: 1, type: UPDATE_FILTER, requestId: req_20250101_001, payload: { filterId: region, value: west }, token: 加密或签名串 }version协议版本号方便未来做兼容老iframe可以不升级父页面收到低版本消息走老逻辑。type业务事件类型接收方通过它分发不要靠event.data里的某个字段做switch。requestId唯一请求标识。如果父页面需要向子页面“要数据”可以用requestId做请求-响应对应。payload真正的业务数据。token在敏感操作场景下可以用约定的签名算法对version type payload做签名接收方验签通过才执行。这一步比较重一般用于涉及资金、权限变更的跨系统通信普通报表联动可以不用。接收方在拿到消息后除了校验来源还要对payload做“防呆处理”。比如期望filterId是字符串就不要假定它一定是字符串加一个类型判断能避免很多线上诡异Bug。4.4 一个实际攻击场景假设父页面有个监听器直接执行了eval(event.data.code)或把event.data内容渲染到页面里但没校验event.origin。此时攻击者只需要在受害者浏览器里打开一个自己控制的页面再通过弹窗或iframe同时存在同一个top窗口下往父页面postMessage发送攻击payload就能实现DOM XSS或信息窃取。代码出处不在你这边但受害的是你。所以“来源校验协议校验数据校验”三管齐下不是过度设计而是面对真实的跨域窗口攻击面的最低要求。5. 调试复盘和踩过的坑监听器重复、发送时机、框架页面清理5.1 监听器重复绑定是定位最久的问题React或Vue项目里最容易踩一个隐性坑message监听器在组件生命周期里重复绑定。// 反例没有清理监听器 useEffect(() { window.addEventListener(message, handler); }, []);这个写法在开发环境不报错因为组件只挂载一次。但如果组件在某种条件下被卸载再重挂或者热更新触发监听器就会越积越多。最终结果就是父页面只发了一次消息子页面却同步执行了多次操作。表现可能是列表连续刷新两遍按钮弹窗出现两次特别难排查。解决办法很简单在effect的清理函数里移除监听器useEffect(() { const handler function (event) { // ... }; window.addEventListener(message, handler); return () { window.removeEventListener(message, handler); }; }, []);如果用Vue 3同样在onBeforeUnmount里移除监听器。千万注意事件处理函数必须是同一个引用也就是说别在模板里写匿名函数然后remove时又写一个匿名函数那是删不掉的。5.2 iframe还没加载完就开始发消息在开发环境本地加载快问题不明显部署到线上后子页面静态资源一旦加载慢就会经常出现“父页面发了配置子页面完全没收到”。我见过一个线上问题排查到最后发现是子页面在iframe的load事件之后还要经过一个登录流程登录态初始化完成才注册message监听器。这段窗口期足有几秒钟父页面早就把初始化消息发完了。不要再依赖“延时发送”用上一节讲的握手事件才是正解。子页面内部真正初始化完成后主动发READY消息父页面收到后再把需要下发的配置发一遍。这招几乎能覆盖所有“子页面不急时接收”的场景。5.3 跨域window访问被拒绝时的报错迷局跨域情况下如果你尝试访问iframe.contentWindow.document浏览器会直接抛SecurityError。有些同学用try/catch包裹后发现连contentWindow本身都能拿到就误以为“跨域已经打通”。其实跨域下能拿到的只是window的极少数属性比如postMessage、closed等拿不到实质DOM内容。区分两类问题可以看控制台报错形态现象大概率原因SecurityError: Blocked a frame from accessing a cross-origin frame直接访问了跨域iframe的DOM属性页面无报错但消息一直收不到发送时机太早或targetOrigin不匹配消息收到但event.origin不符合预期页面部署域名和代码里写死的origin不一致遇到第三种情况先在接收端临时把event.origin打出来看一眼再和代码里的白名单比对通常能很快定位。不要在控制台里调试postMessage时把页面锁死那容易把自己绕晕。5.4 安全策略拦在postMessage之前有时候postMessage代码写对了但iframe压根加载不出来这又是另一层问题CSP内容安全策略或X-Frame-Options头。父页面如果设置了严格的frame-src会直接阻止加载第三方iframe子页面如果返回X-Frame-Options: DENY或Content-Security-Policy: frame-ancestors none同样也会拒绝被嵌入。有些开源系统比如部分数据报表工具就明确禁止被iframe嵌入主要也是出于安全考虑。这类情况不是postMessage能解决的需要从部署配置层放行。遇到iframe白屏时先开浏览器Network面板看iframe的响应头再确定是安全策略拦截还是业务代码问题。排查顺序建议iframe有没有发出请求如果没有查父页面CSP的frame-src。iframe请求状态200但在页面里空白查子页面响应头的X-Frame-Options和frame-ancestors。iframe正常渲染但通信不通回到postMessage的origin、发送时机检查。5.5 消息协议设计建议与个人习惯最后分享一点我自己的实际习惯。做跨域消息通信最容易出问题的不是API不会用而是“没做好消息模型”。我在项目里把消息统一分为三类命令类Command父页面要子页面执行某些操作比如SET_THEME、TOGGLE_FULLSCREEN。事件类Event子页面发生了一些事情通知父页面比如REPORT_SELECT、REPORT_ERROR。请求-响应类Request/Response父页面需要主动向子页面要数据比如“把当前选中的数据集ID上报一下”。这类消息一定要带requestId否则父页面无法区分哪条响应属于哪次请求。协议名称上多用名词_动词或业务_状态这种组合尽量少用MSG_1、MSG_2这种不可读命名。项目一旦大了消息目录就是你们团队跨系统的接口文档清晰度比什么都重要。还有一个小技巧所有postMessage业务消息里附带一个timestamp字段。排查消息延迟、消息乱序时有据可查。尤其当iframe和父页面各自有定时器、轮询请求时有了时间戳就能快速判断到底是发送问题还是接收问题。跨域消息通信这块API本身并不复杂真正的复杂度全在时序、安全边界和消息模型设计上。我在实际项目里把消息协议迭代了好几轮越往后越觉得“先定好协议、再写通信代码”比什么都重要。如果你正准备在系统里接入iframe跨域通信建议先把本文的几个章节串起来看先搞懂API怎么用再按父子双向通信的示例搭一套骨架安全校验三道防线一条都别省最后把握手流程加上。这套流程走下来线上踩坑的概率能低很多。
返回列表