题干与适用场景
主站运行在 https://app.example.com,嵌入来自 https://pay.example.net 的支付 iframe。主站需要在组件就绪后发送语言、主题和短时效的支付会话引用;iframe 回报 ready、高度变化、用户取消和支付流程完成。页面可能同时存在多个相同来源的 iframe,组件还可能在加载期间重定向或被重新创建。
请设计双向 postMessage 协议和前端实现,覆盖发送对象、接收者身份、消息格式、初始化时序、重复与乱序、导航、资源清理和安全测试。支付完成消息只用于推动界面刷新,最终支付状态必须由主站服务端向权威支付系统确认。
这道题适合资深前端、Web 平台、前端架构与应用安全岗位。核心能力是浏览器同源策略、跨文档消息、客户端信任边界和异步协议设计,因此归入 frontend。CORS 管理浏览器脚本能否读取跨源网络响应;它不会替代 postMessage 的发送目标和消息监听器校验。
面试官考察点
第一个信号是能否同时保护发送端与接收端。发送时要给出精确 targetOrigin,防止目标窗口导航后由其他来源收到数据;接收时每条消息都要检查 event.origin,并在已知窗口引用时检查 event.source。只做其中一半仍会留下泄露或伪造路径。
第二个信号是能否把消息当作公开入口的未知输入。通过 origin 校验只说明代码运行在哪个来源;它不能证明数据结构正确,也不能阻止可信来源中的 XSS 或错误代码发送危险命令。强回答会定义带版本的封包、消息类型、字段约束、大小上限和状态转换,再把 event.data 从 unknown 收窄。
第三个信号是能否处理异步时序。父页面在 iframe 注册监听器前发送初始化会静默丢失。可靠流程是父页面先注册监听器,再加载 iframe,由子页面发 ready,父页面验证后才发 init。消息 ID、通道 ID、超时和幂等状态转换负责处理重试、重复和迟到消息。
最后看信任边界是否落到服务端。来自已验证 iframe 的 complete 仍不是付款凭证;前端应拿不含敏感详情的结果引用查询主站服务端。CSP、frame-src、frame-ancestors 和最小化 sandbox 权限可以缩小攻击面,但不会替代消息级身份与数据校验。
回答前需要澄清的问题
- 双方来源是否固定? 固定的两个 origin 可以精确比较。若主站允许客户自定义域名,支付页需要从服务端获得该会话允许的父 origin,不能把任意
event.origin首次出现就记为可信。 - 消息包含什么敏感数据? 主题和高度风险较低;支付凭证、个人信息或可复用 bearer token 不应通过广播式接口传递。确需传递能力时,应使用短时效、限定受众、可撤销且最小权限的引用。
- 谁能嵌入支付页? 支付页应通过
frame-ancestors限制允许嵌入者。若合法租户很多,需要由服务端生成准确策略或使用受控嵌入入口,而不是允许所有站点。 - 同页会有几个相同来源的 iframe? 只有一个时,origin 加预期
contentWindow足够定位。多个实例必须分别保存窗口引用,并用通道 ID 约束协议状态,不能只按 origin 广播处理。 - 哪些消息能触发敏感动作?
resize可以直接作用于布局;complete、退款、提交订单或账户变更必须经过服务端鉴权与权威状态查询。消息只携带动作提示,不授予业务权限。 - 失败后允许重试什么?
ready和init可以幂等重试;支付提交不能因消息重发而重复执行。需要明确请求 ID、终态和服务端幂等键分别由谁维护。
30 秒回答框架
“我会把 postMessage 当作一个跨信任边界的异步 API。父页先注册监听器并保存 iframe 的 contentWindow,子页加载后发送 ready;父页逐条校验精确 origin、预期 source 和消息 schema,再用精确 targetOrigin 回复 init。协议带版本、通道 ID和消息 ID,用状态机拒绝未握手、重复、乱序和终态后的消息。complete 只触发主站向服务端查询权威支付状态,不直接当成成功。最后用恶意 origin、同源错误 iframe、畸形消息、重放和导航竞态做负向测试,并用 CSP 与最小 iframe 权限缩小暴露面。”
分步骤深入解答
第一步:先画出两个方向的信任边界
父页发送前有两个问题:拿到的 Window 引用是不是目标 iframe,以及调用发生时目标文档的 origin 是否仍符合预期。targetOrigin 解决第二个问题;若目标已经导航到其他 origin,浏览器会丢弃消息。使用 "*" 会放弃这项收件人限制。
父页接收时也有两个独立问题:消息来自哪个 origin,以及来自该 origin 下哪一个窗口。任何能取得当前窗口引用的页面都可以尝试发送消息;相同可信 origin 下的另一个 iframe 也可能发出同名事件。因此接收器至少检查:
event.origin与完整的协议、主机和端口精确相等;event.source与当前 iframe 保存的contentWindow是同一个引用;event.data满足当前协议和当前状态允许的消息结构。
不要使用后缀包含、正则片段或 indexOf 判断 origin。https://pay.example.net.attacker.test 可以通过粗糙的字符串包含检查。也不要把 event.origin 动态回填到 allowlist;这会把攻击者的首次探测变成注册流程。
第二步:定义小而明确的消息协议
把所有消息写成可区分联合,而不是任意对象或字符串命令。一个精简协议可以包含:
| 字段 | 用途 | 校验 |
|---|---|---|
v | 协议版本 | 仅接受已支持的整数版本 |
type | 消息类型 | 固定枚举,不执行动态函数名 |
messageId | 去重与审计关联 | 非空、长度受限、当前窗口内唯一 |
channelId | 绑定一次成功握手 | ready 后由父页生成,后续必须精确匹配 |
| 业务字段 | 该消息所需的最少数据 | 逐字段类型、范围和长度校验 |
ready 在通道建立前没有 channelId;父页通过已经验证的 origin 与 source 接收它,生成随机 channelId,再发送 init。之后的 resize、cancel 和 complete 都必须回传该通道 ID。通道 ID用于隔离同一窗口的旧会话和迟到消息,不代替 origin、source 或服务端授权。
接收器把数据从 unknown 开始处理。下面是父页的简化 TypeScript 示例;parseWidgetMessage 代表严格的运行时校验,不是类型断言:
const WIDGET_ORIGIN = "https://pay.example.net"
const frame = document.querySelector<HTMLIFrameElement>("#payment-widget")
if (!frame?.contentWindow) {
throw new Error("Payment iframe is unavailable")
}
const widgetWindow = frame.contentWindow
let channelId: string | null = null
const seenMessageIds = new Set<string>()
let state: "loading" | "active" | "completed" | "closed" = "loading"
window.addEventListener("message", onWidgetMessage)
frame.src = "https://pay.example.net/embed"
function onWidgetMessage(event: MessageEvent<unknown>) {
if (event.origin !== WIDGET_ORIGIN) return
if (event.source !== widgetWindow) return
const message = parseWidgetMessage(event.data)
if (!message || seenMessageIds.has(message.messageId)) return
seenMessageIds.add(message.messageId)
if (message.type === "ready") {
if (state !== "loading") return
channelId = crypto.randomUUID()
widgetWindow.postMessage(
{
v: 1,
type: "init",
messageId: crypto.randomUUID(),
channelId,
locale: "zh-CN",
theme: "system",
checkoutSessionRef: "short-lived-opaque-reference",
},
WIDGET_ORIGIN,
)
state = "active"
return
}
if (state !== "active" || channelId === null || message.channelId !== channelId) return
if (message.type === "resize") {
frame.style.height = `${Math.min(Math.max(message.height, 240), 900)}px`
}
if (message.type === "complete") {
state = "completed"
void refreshAuthoritativePaymentStatus(message.resultRef)
}
}实际解析器还要拒绝额外危险类型、超长字符串、非有限数字和不允许的状态组合。不要用 as WidgetMessage 跳过运行时检查。结构化克隆允许发送对象,不代表对象符合业务协议。
子页使用同一套规则处理反方向:它只接受预先配置或由服务端会话绑定的主站 origin,要求 event.source === window.parent,验证 init schema 后才保存通道 ID,并始终用精确的主站 origin 回发消息。父页与子页都不能因自己“只接收一个伙伴”而省略检查。
第三步:用握手和状态机消除时序歧义
HTML 标准明确提示,新导航到的子文档可能尚未安装消息监听器,父页此时发送的消息不会排队等它准备好。父页应先安装自己的监听器,再设置或确认 iframe 地址;子页安装监听器后发送 ready。父页只有在 ready 通过 origin、source 和 schema 校验后才发送敏感初始化数据。
状态机把允许动作写清楚:
| 当前状态 | 接受的消息 | 处理后状态 |
|---|---|---|
loading | ready | active |
active | resize、cancel、complete | 保持、closed 或 completed |
completed | 无业务消息;允许只读确认 | 保持 completed |
closed | 无 | 保持 closed |
父页可以对握手设置超时,超时后显示可重试错误并销毁旧 iframe。重建时必须生成新通道 ID、清空去重集合、更新预期窗口引用,并移除旧监听器。不能复用旧通道,否则旧页面的迟到消息可能落入新会话。
第四步:区分重复消息、重复业务动作和服务端事实
消息传递与业务执行是两层幂等性。前端用 messageId 忽略已处理的重复 UI 消息,用状态机拒绝终态后的变化。服务端仍需用订单或支付尝试的幂等键防止重复扣款,因为页面刷新、网络重试和多个标签页都可能绕过前端集合。
complete 只表示可信窗口声称流程完成。父页把 resultRef 发送给自己的服务端,服务端校验当前用户、订单归属、金额和支付提供方的权威状态,再返回可展示结果。即使支付 iframe 所在 origin 被 XSS 控制,伪造 complete 也不能直接把订单改成已付款。
消息日志只记录协议版本、类型、通道哈希、结果和拒绝原因,不记录支付凭证、完整个人信息或可复用 token。去重集合应设数量上限或随通道销毁,避免攻击或长期页面让内存无限增长。
第五步:处理导航、多个实例和共享 origin
event.origin 是发送方调用 postMessage 当时的 origin,不保证发送窗口现在或未来仍在该 origin。每一条消息都必须重新校验,不能在握手通过后永久信任窗口。目标 iframe 若导航到攻击者 origin,精确 targetOrigin 会阻止父页继续发送,父页接收器也会因 origin 不匹配拒绝新消息。
如果 iframe 导航到同一可信 origin 的另一应用,origin 检查仍会通过。source、通道 ID、协议版本、消息类型和服务端确认可以限制旧会话、误路由和业务影响,却不能抵抗同一 origin 上的恶意代码;浏览器把整个 origin 当作一个安全主体。信任等级不同的应用必须使用不同子域,不能只靠 URL 路径或消息字段隔离。
同页有多个支付 iframe 时,为每个元素保存自己的 contentWindow、状态、通道和监听处理。可以用一个总监听器路由到实例,但路由键必须先来自 event.source 的引用映射,不能先信任消息中的实例 ID。
第六步:限制 iframe 与嵌入关系的权限
主站 CSP 的 frame-src 只允许加载批准的支付 origin;支付站点的 frame-ancestors 只允许获准主站嵌入。iframe 的 sandbox 只开启支付流程真正需要的能力,例如脚本、表单或特定弹窗。每增加一项权限都应对应一个可验证需求。
这些控制解决的是“谁能加载谁”和嵌入文档拥有哪些浏览器能力。它们不能验证某条消息,也不能让 "*" 变安全。支付组件若允许任意站点嵌入,攻击者可能在自己的页面里加载已登录组件并主动发送指令;frame-ancestors 可以直接收窄这条攻击路径。
若握手后需要高频、独立的双向流,可以通过已验证的首条 postMessage 转移一个 MessagePort。这能把后续通信移到专用端口,减少全局 message 监听器之间的干扰;初始端口交付仍必须完成 origin、source 和 schema 校验,持有端口也仍不等于拥有服务端业务权限。
第七步:用负向测试证明拒绝路径
正向测试只能证明合法页面能工作。安全验证要构造本应被拒绝的输入:
| 测试 | 预期结果 |
|---|---|
攻击者 origin 发送合法外形的 complete | origin 校验拒绝,不查询支付状态 |
| 同一可信 origin 的另一个 iframe 发送消息 | source 校验拒绝 |
| 正确 origin 与 source 发送未知类型或超大字段 | schema 校验拒绝 |
重放同一 messageId | 只处理一次 |
| 旧通道在 iframe 重建后发送迟到消息 | channel 校验拒绝 |
| iframe 在父页发送前导航到其他 origin | 精确 targetOrigin 导致消息丢弃 |
complete 携带不存在或他人的结果引用 | 服务端鉴权与权威查询拒绝 |
ready 延迟、重复或永远不来 | 幂等处理或超时进入可重试失败态 |
代码审查同时搜索所有 postMessage 调用中的 "*"、所有 message 监听器、模糊 origin 比较、未检查的 event.data 和把消息写入 innerHTML 的路径。运行浏览器集成测试时还要验证监听器卸载、iframe 重建、多个实例和路由切换。
高质量示范回答
“我先把通信分成发送和接收两个边界。父页只向保存下来的 iframe contentWindow 发送,并把 https://pay.example.net 作为精确 targetOrigin。监听器对每条消息都比较完整 event.origin 和同一个 contentWindow 引用,然后再做运行时 schema 校验。只检查 origin 不够,因为页面可能有多个同源 iframe;只检查 source 也不够,因为该窗口可能已经导航。
我会定义版本化的少量消息:ready、init、resize、cancel 和 complete。父页先监听再加载 iframe,子页安装监听器后发 ready,父页验证通过才生成通道 ID并发送初始化。后续消息必须带同一通道 ID与唯一消息 ID;前端用状态机和去重集合拒绝未握手、重复、乱序以及完成后的状态回退。握手超时就销毁旧 iframe,重试时创建新窗口引用和新通道。
支付完成消息不会直接改变订单。它只携带不透明结果引用,让主站服务端校验用户和订单,再向支付系统查询权威状态。这样可信支付 origin 中的脚本出错甚至发生 XSS,也不能仅靠一条消息伪造付款。
我还会让主站通过 frame-src 限制可加载的组件,让支付页通过 frame-ancestors 限制嵌入者,并给 iframe 最小 sandbox 权限。验证时除了合法流程,还会从攻击者 origin、错误同源 iframe、旧通道和重放消息发起测试,模拟 iframe 导航与 ready 超时,并确认所有拒绝路径不会触发敏感业务动作。”
常见错误
- 发送时使用
"*"→ 目标窗口可能已导航到攻击者页面,敏感数据仍会被投递 → 始终使用预期的精确targetOrigin。 - 接收时只检查
event.data.type→ 任意能取得窗口引用的页面都能伪造同名命令 → 先校验精确 origin 和预期 source,再解析数据。 - 使用域名包含或后缀片段判断 origin → 攻击者域名可以包含受信字符串 → 比较浏览器提供的完整规范化 origin。
- TypeScript 类型断言后直接处理 → 类型在运行时不存在,畸形值仍会进入逻辑 → 从
unknown开始做严格 schema、范围和状态校验。 - 页面加载后立即发送
init→ iframe 监听器可能尚未注册,消息静默丢失 → 由子页发ready,验证后再初始化并设置超时。 - 把
complete当作付款成功 → 前端消息不是业务权威事实,可信 origin 也可能失陷 → 由主站服务端鉴权并查询权威支付状态。 - 握手通过后不再检查 origin → 窗口可以在会话中导航,旧信任会跨文档延续 → 逐条校验并用新通道隔离重建会话。
- 认为 CORS 或 sandbox 已经保护消息 → 它们分别约束网络读取和文档能力,不验证消息发送者与数据 → 保留消息级校验,并把浏览器策略作为纵深防御。
追问及应对
追问一:支付组件要支持数千个客户自定义域名,子页如何知道父 origin?
不要把第一条消息的 event.origin 自动加入信任列表。主站先向服务端创建嵌入会话,服务端验证客户域名归属并把允许的父 origin 绑定到短时效会话。支付页加载时根据该会话取得预期 origin,发送和接收都只使用这个值;会话过期、域名变更或窗口重建都重新授权。若无法可靠证明自定义域名归属,就不能给它敏感嵌入能力。
追问二:多个同源 iframe 都需要通信,能否只靠 channelId 路由?
不能先信任消息自己声明的通道。全局监听器先用 event.source 在已登记的 Window 引用映射中找到实例,再检查该实例的 origin、状态和通道 ID。通道 ID用于拒绝旧会话和乱序消息;窗口引用负责定位浏览上下文,两者职责不同。
追问三:iframe 会先经过同一支付域的登录页,再导航到结账页,握手怎么办?
同一 origin 的登录页与结账页属于同一个浏览器安全主体,父页无法从 event.origin 得知具体路径。若两者同等可信,父页在每次 iframe load 后废弃旧通道并回到 loading,等新文档发出符合会话契约的 ready 后重新握手;这能拒绝旧文档的迟到消息。若登录页不应看到初始化数据或信任等级较低,就必须把它放到不同 origin,并为结账页建立独立嵌入入口,不能指望通道 ID弥补同源隔离缺失。
追问四:使用 MessageChannel 后还需要检查 origin 吗?
端口消息本身没有与 window 消息相同的 origin 字段,所以安全性取决于端口最初交给了谁。交付端口的第一条 postMessage 必须校验精确 origin、source 和握手状态;端口只能在当前会话使用并在关闭时销毁。专用端口能减少误路由,但不能替代初始认证、消息 schema 或服务端授权。
追问五:业务要求 iframe 主动通知自动调整高度,怎样防止页面被撑到异常尺寸?
resize 通过身份校验后仍是不可信业务输入。协议只接受有限整数,父页按布局允许的最小和最大高度夹取,并限制更新频率;超限或高频消息记录拒绝原因。若内容确实需要更高尺寸,改为内部滚动或由产品定义新的上限,不能让子页直接控制任意 CSS。