feat: 更新环境配置并增强 iframe 安全处理机制

修改 .env.example,优化环境切换说明,并新增 API_BASE_URL 配置项,提升配置管理能力。
更新 next.config.ts:使用 API_BASE_URL 代理 API 请求,增强开发与生产环境的灵活性。
重构 iframe-bridge 与 use-token-refresh 组件,采用新的 iframe 来源校验方法,提升安全性检查能力。
优化 csp-config.ts:动态注入允许的父级来源(parent origins)到 CSP 配置中,强化安全策略。
调整 lottery-http:通过 Next.js 代理转发 API 请求,简化 API 调用流程。
This commit is contained in:
2026-05-28 10:12:24 +08:00
parent 58afa8e844
commit 1316a62ce3
8 changed files with 203 additions and 54 deletions

View File

@@ -4,6 +4,10 @@ import { useEffect, useCallback, type ReactNode } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { setPlayerBearerToken } from "@/lib/lottery-auth";
import {
isIframeOriginAllowed,
loadIframeAllowedOrigins,
} from "@/lib/iframe-origins";
/**
* iframe 通信桥接组件
@@ -87,21 +91,13 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
console.log("[IframeBridge] Setting up iframe communication");
const handleMessage = (event: MessageEvent): void => {
// 安全:验证来源域名
const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
"http://localhost:3800",
"http://127.0.0.1:3800",
].filter(Boolean);
if (
allowedOrigins.length > 0 &&
!allowedOrigins.includes(event.origin)
) {
console.warn("[IframeBridge] Rejected message from:", event.origin);
return;
const handleMessage = async (event: MessageEvent): Promise<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins();
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[IframeBridge] Rejected message from:", event.origin);
return;
}
}
const { data } = event;
@@ -158,8 +154,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
window.addEventListener("message", handleMessage);
// 发送就绪通知
notifyReady();
// 先加载后台白名单,再发送 READY避免父站立即回 Token 时被本端误拒。
void loadIframeAllowedOrigins().finally(() => {
notifyReady();
});
// 定期发送心跳
const heartbeat = setInterval(() => {

View File

@@ -2,6 +2,10 @@ import { useCallback, useEffect, useRef } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { useErrorStore } from "@/stores/error-store";
import {
isIframeOriginAllowed,
loadIframeAllowedOrigins,
} from "@/lib/iframe-origins";
/** Token 过期前警告阈值(毫秒) */
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
@@ -116,21 +120,15 @@ export function useTokenRefresh(): {
useEffect(() => {
if (typeof window === "undefined") return;
const handleMessage = (event: MessageEvent): void => {
// 安全检查:验证来源
const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
// 开发环境允许本地
"http://localhost:3800",
"http://127.0.0.1:3800",
].filter(Boolean);
void loadIframeAllowedOrigins();
if (
allowedOrigins.length > 0 &&
!allowedOrigins.includes(event.origin)
) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;
const handleMessage = async (event: MessageEvent): Promise<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins();
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;
}
}
const { data } = event;

View File

@@ -18,10 +18,37 @@ const ALLOWED_PARENT_ORIGINS: string[] = [
// 生产环境应从环境变量读取
].filter((o): o is string => Boolean(o));
function normalizeOrigin(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
export function staticAllowedParentOrigins(): string[] {
return Array.from(
new Set(
ALLOWED_PARENT_ORIGINS
.map((origin) => normalizeOrigin(origin))
.filter((origin): origin is string => origin !== null),
),
);
}
/**
* 生成 CSP 指令字符串
*/
export function generateCSP(): string {
export function generateCSP(extraParentOrigins: string[] = []): string {
const parentOrigins = Array.from(
new Set([
...staticAllowedParentOrigins(),
...extraParentOrigins
.map((origin) => normalizeOrigin(origin))
.filter((origin): origin is string => origin !== null),
]),
);
const directives: Record<string, string[]> = {
// 默认只允许同源
"default-src": ["'self'"],
@@ -42,7 +69,7 @@ export function generateCSP(): string {
"connect-src": [
"'self'",
process.env.NEXT_PUBLIC_API_URL || "",
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL || "",
process.env.API_BASE_URL || "",
// WebSocket 连接
"ws:",
"wss:",
@@ -55,10 +82,10 @@ export function generateCSP(): string {
"object-src": ["'none'"],
// 框架允许同源和指定父站
"frame-src": ["'self'", ...ALLOWED_PARENT_ORIGINS],
"frame-src": ["'self'", ...parentOrigins],
// 允许被嵌入到指定父站
"frame-ancestors": ["'self'", ...ALLOWED_PARENT_ORIGINS],
"frame-ancestors": ["'self'", ...parentOrigins],
// 表单提交允许同源
"form-action": ["'self'"],
@@ -88,11 +115,7 @@ export function isAllowedParent(parentOrigin: string): boolean {
/**
* 安全头配置(用于 next.config.ts
*/
export const securityHeaders = [
{
key: "Content-Security-Policy",
value: generateCSP(),
},
export const nonCspSecurityHeaders = [
{
key: "X-Content-Type-Options",
value: "nosniff",
@@ -106,3 +129,11 @@ export const securityHeaders = [
value: "camera=(), microphone=(), geolocation=()",
},
];
export const securityHeaders = [
{
key: "Content-Security-Policy",
value: generateCSP(),
},
...nonCspSecurityHeaders,
];

78
src/lib/iframe-origins.ts Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { lotteryHttp, unwrapData } from "@/lib/lottery-http";
import { API_V1_PREFIX } from "@/api/paths";
type RuntimeOriginsResponse = {
iframe_allowed_origins: string[];
};
let cachedOrigins: string[] | null = null;
let pendingOrigins: Promise<string[]> | null = null;
function normalizeOrigin(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).origin;
} catch {
return null;
}
}
function staticAllowedOrigins(): string[] {
return [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
"http://localhost:3800",
"http://127.0.0.1:3800",
]
.map(normalizeOrigin)
.filter((origin): origin is string => origin !== null);
}
function uniqueOrigins(origins: string[]): string[] {
return Array.from(new Set(origins));
}
export function getKnownIframeAllowedOrigins(): string[] {
return uniqueOrigins([
...staticAllowedOrigins(),
...(cachedOrigins ?? []),
]);
}
export async function loadIframeAllowedOrigins(): Promise<string[]> {
if (cachedOrigins !== null) {
return getKnownIframeAllowedOrigins();
}
pendingOrigins ??= lotteryHttp
.get(`${API_V1_PREFIX}/integration/runtime-origins`)
.then((response) => {
const data = unwrapData<RuntimeOriginsResponse>(response.data);
cachedOrigins = data.iframe_allowed_origins
.map(normalizeOrigin)
.filter((origin): origin is string => origin !== null);
return getKnownIframeAllowedOrigins();
})
.catch((error: unknown) => {
pendingOrigins = null;
console.warn("[IframeOrigins] Failed to load runtime origins:", error);
return getKnownIframeAllowedOrigins();
});
return pendingOrigins;
}
export function isIframeOriginAllowed(origin: string): boolean {
const normalized = normalizeOrigin(origin);
if (normalized === null) return false;
const allowedOrigins = getKnownIframeAllowedOrigins();
if (allowedOrigins.length === 0) return true;
return allowedOrigins.includes(normalized);
}

View File

@@ -14,13 +14,12 @@ import {
import { isApiEnvelope } from "@/types/api/envelope";
import { useErrorStore } from "@/stores/error-store";
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
/**
* **第一层**:只挂 `baseURL` / `timeout` / 默认 `Accept`。业务解析、toast 都不在这里。
*/
export const lotteryHttp = axios.create({
baseURL: baseURL && baseURL !== "" ? baseURL : undefined,
// 统一走 Next 同源 /api 代理,由 next.config.ts 的 API_BASE_URL 转发到后端。
baseURL: "/api",
timeout: 30_000,
headers: { Accept: "application/json" },
});