diff --git a/.env.example b/.env.example index 7ef3261..cf62d6d 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ # ============================================================================= # 前端本地配置示例 # ============================================================================= +# 手动切换环境:保留一个生效,另一个注释掉 -# Next 开发服务代理目标:浏览器请求 /api/* 时由 Next 转发到这里。 -# 默认值已经在 next.config.ts 中兜底为 http://127.0.0.1:8000;本地 Laravel 端口不同时再改。 -LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000 - -# 可选:如果设置此值,浏览器会绕过 Next 代理,直接请求该 API 地址。 -# 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。 -# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000 +# 测试 +API_BASE_URL=http://127.0.0.1:8000 +# 线上 +# API_BASE_URL=https://api.your-production-domain.com # 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。 # NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..6261f06 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,47 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config"; + +type RuntimeOriginsEnvelope = { + code?: number; + data?: { + iframe_allowed_origins?: unknown; + }; +}; + +async function loadRuntimeOrigins(request: NextRequest): Promise { + try { + const url = new URL("/api/v1/integration/runtime-origins", request.url); + const response = await fetch(url, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) return []; + + const payload = (await response.json()) as RuntimeOriginsEnvelope; + const origins = payload.data?.iframe_allowed_origins; + + if (!Array.isArray(origins)) return []; + + return origins.filter((origin): origin is string => typeof origin === "string"); + } catch { + return []; + } +} + +export async function middleware(request: NextRequest): Promise { + const response = NextResponse.next(); + const runtimeOrigins = await loadRuntimeOrigins(request); + + response.headers.set("Content-Security-Policy", generateCSP(runtimeOrigins)); + for (const header of nonCspSecurityHeaders) { + response.headers.set(header.key, header.value); + } + + return response; +} + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"], +}; diff --git a/next.config.ts b/next.config.ts index 04a8486..d69ab6e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,20 +1,20 @@ import type { NextConfig } from "next"; -import { securityHeaders } from "./src/lib/csp-config"; +import { nonCspSecurityHeaders } from "./src/lib/csp-config"; const lotteryApiProxyTarget = - process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000"; + process.env.API_BASE_URL?.trim() || "http://127.0.0.1:8000"; const nextConfig: NextConfig = { allowedDevOrigins: ["192.168.0.101"], reactCompiler: true, - // 安全头配置 - 支持 iframe 嵌入 + // 非 CSP 安全头;CSP 由 middleware 按后台接入站点白名单动态生成。 async headers() { return [ { source: "/:path*", - headers: securityHeaders, + headers: nonCspSecurityHeaders, }, ]; }, diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index b636358..e8acb34 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -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 => { + 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(() => { diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index e958651..af2f814 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -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 => { + if (!isIframeOriginAllowed(event.origin)) { + await loadIframeAllowedOrigins(); + if (!isIframeOriginAllowed(event.origin)) { + console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin); + return; + } } const { data } = event; diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index d032002..e5f9852 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -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 = { // 默认只允许同源 "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, +]; diff --git a/src/lib/iframe-origins.ts b/src/lib/iframe-origins.ts new file mode 100644 index 0000000..02fd5a9 --- /dev/null +++ b/src/lib/iframe-origins.ts @@ -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 | 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 { + if (cachedOrigins !== null) { + return getKnownIframeAllowedOrigins(); + } + + pendingOrigins ??= lotteryHttp + .get(`${API_V1_PREFIX}/integration/runtime-origins`) + .then((response) => { + const data = unwrapData(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); +} diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index d2981f8..ec29f52 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -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" }, });