fix(iframe): validate trusted parent messages

This commit is contained in:
wchino
2026-07-21 23:41:18 +08:00
parent 9b3eae1896
commit 5ef2e09b04
3 changed files with 64 additions and 31 deletions

View File

@@ -5,9 +5,10 @@ import { useEffect, useCallback, type ReactNode } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { setPlayerBearerToken } from "@/lib/lottery-auth";
import {
isIframeOriginAllowed,
loadIframeAllowedOrigins,
messageToken,
resolvePostMessageTargetOrigin,
resolveTrustedParentMessage,
} from "@/lib/iframe-origins";
function sanitizeUrlForParent(href: string): string {
@@ -123,39 +124,38 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
console.log("[IframeBridge] Setting up iframe communication");
const handleMessage = async (event: MessageEvent): Promise<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins(true);
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[IframeBridge] Rejected message from:", event.origin);
return;
}
const data = await resolveTrustedParentMessage(event);
if (data === null) {
console.warn("[IframeBridge] Rejected untrusted parent message from:", event.origin);
return;
}
const { data } = event;
if (!data || typeof data !== "object") return;
console.log("[IframeBridge] Received message:", data.type);
switch (data.type) {
// 主站发送初始化 Token
case "MAIN_INIT_TOKEN":
if (data.token) {
case "MAIN_INIT_TOKEN": {
const token = messageToken(data);
if (token !== null) {
console.log("[IframeBridge] Received initial token");
setBearerToken(data.token);
setPlayerBearerToken(data.token);
setBearerToken(token);
setPlayerBearerToken(token);
// 勿再 notifyReady(),否则主站会重复 MAIN_INIT_TOKEN 导致消息刷屏
}
break;
}
// 主站刷新 Token
case "MAIN_REFRESH_TOKEN":
if (data.token) {
case "MAIN_REFRESH_TOKEN": {
const token = messageToken(data);
if (token !== null) {
console.log("[IframeBridge] Received refreshed token");
setBearerToken(data.token);
setPlayerBearerToken(data.token);
setBearerToken(token);
setPlayerBearerToken(token);
notifyTokenRefreshed();
}
break;
}
// 主站通知 Token 即将过期
case "MAIN_TOKEN_EXPIRING":
@@ -173,7 +173,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
// 主站导航请求
case "MAIN_NAVIGATE":
if (data.path && typeof data.path === "string") {
if (typeof data.path === "string") {
const nextPath = resolveSafeInAppPath(data.path);
if (nextPath) {
window.history.pushState({}, "", nextPath);

View File

@@ -5,9 +5,10 @@ import { parseJwtExp } from "@/lib/jwt-payload";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store";
import {
isIframeOriginAllowed,
loadIframeAllowedOrigins,
messageToken,
resolvePostMessageTargetOrigin,
resolveTrustedParentMessage,
} from "@/lib/iframe-origins";
/** Token 过期前警告阈值(毫秒) */
@@ -118,25 +119,21 @@ export function useTokenRefresh(): {
void loadIframeAllowedOrigins();
const handleMessage = async (event: MessageEvent): Promise<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins(true);
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;
}
const data = await resolveTrustedParentMessage(event);
if (data === null) {
console.warn("[TokenRefresh] Ignored untrusted parent message from:", event.origin);
return;
}
const { data } = event;
if (!data || typeof data !== "object") return;
// 处理主站发送的新 Token兼容 MAIN_REFRESH_TOKEN 与 LOTTERY_TOKEN_REFRESH_RESPONSE
const token = messageToken(data);
if (
(data.type === "LOTTERY_TOKEN_REFRESH_RESPONSE" || data.type === "MAIN_REFRESH_TOKEN") &&
data.token
token !== null
) {
console.log("[TokenRefresh] Received new token from parent");
pendingRefreshRef.current = 0;
setBearerToken(data.token);
setBearerToken(token);
retryCountRef.current = 0;
}

View File

@@ -111,6 +111,42 @@ export function isIframeOriginAllowed(origin: string): boolean {
return allowedOrigins.includes(normalized);
}
function isPlainMessageObject(value: unknown): value is Record<string, unknown> {
if (Object.prototype.toString.call(value) !== "[object Object]") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/** 仅接收当前 iframe 的真实父窗口发来的白名单消息。 */
export async function resolveTrustedParentMessage(
event: MessageEvent,
): Promise<Record<string, unknown> | null> {
if (typeof window === "undefined" || window.parent === window) {
return null;
}
if (event.source !== window.parent) {
return null;
}
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins(true);
if (!isIframeOriginAllowed(event.origin)) {
return null;
}
}
return isPlainMessageObject(event.data) ? event.data : null;
}
export function messageToken(data: Record<string, unknown>): string | null {
const token = data.token;
if (typeof token !== "string") return null;
const trimmed = token.trim();
return trimmed === "" ? null : trimmed;
}
/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin无白名单时拒绝发送 */
export function resolvePostMessageTargetOrigin(): string | null {
const allowedOrigins = getKnownIframeAllowedOrigins();