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:
12
.env.example
12
.env.example
@@ -1,14 +1,12 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 前端本地配置示例
|
# 前端本地配置示例
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# 手动切换环境:保留一个生效,另一个注释掉
|
||||||
|
|
||||||
# Next 开发服务代理目标:浏览器请求 /api/* 时由 Next 转发到这里。
|
# 测试
|
||||||
# 默认值已经在 next.config.ts 中兜底为 http://127.0.0.1:8000;本地 Laravel 端口不同时再改。
|
API_BASE_URL=http://127.0.0.1:8000
|
||||||
LOTTERY_API_PROXY_TARGET=http://127.0.0.1:8000
|
# 线上
|
||||||
|
# API_BASE_URL=https://api.your-production-domain.com
|
||||||
# 可选:如果设置此值,浏览器会绕过 Next 代理,直接请求该 API 地址。
|
|
||||||
# 一般本地开发建议留空,让请求走同源 /api 代理,避免 CORS。
|
|
||||||
# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000
|
|
||||||
|
|
||||||
# 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。
|
# 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。
|
||||||
# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR
|
# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR
|
||||||
|
|||||||
47
middleware.ts
Normal file
47
middleware.ts
Normal file
@@ -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<string[]> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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|.*\\..*).*)"],
|
||||||
|
};
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
import { securityHeaders } from "./src/lib/csp-config";
|
import { nonCspSecurityHeaders } from "./src/lib/csp-config";
|
||||||
|
|
||||||
const lotteryApiProxyTarget =
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
allowedDevOrigins: ["192.168.0.101"],
|
allowedDevOrigins: ["192.168.0.101"],
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
// 安全头配置 - 支持 iframe 嵌入
|
// 非 CSP 安全头;CSP 由 middleware 按后台接入站点白名单动态生成。
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/:path*",
|
source: "/:path*",
|
||||||
headers: securityHeaders,
|
headers: nonCspSecurityHeaders,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useEffect, useCallback, type ReactNode } from "react";
|
|||||||
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { setPlayerBearerToken } from "@/lib/lottery-auth";
|
import { setPlayerBearerToken } from "@/lib/lottery-auth";
|
||||||
|
import {
|
||||||
|
isIframeOriginAllowed,
|
||||||
|
loadIframeAllowedOrigins,
|
||||||
|
} from "@/lib/iframe-origins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iframe 通信桥接组件
|
* iframe 通信桥接组件
|
||||||
@@ -87,21 +91,13 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
|
|
||||||
console.log("[IframeBridge] Setting up iframe communication");
|
console.log("[IframeBridge] Setting up iframe communication");
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent): void => {
|
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
// 安全:验证来源域名
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
const allowedOrigins = [
|
await loadIframeAllowedOrigins();
|
||||||
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
|
console.warn("[IframeBridge] Rejected message from:", event.origin);
|
||||||
"http://localhost:3800",
|
return;
|
||||||
"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 { data } = event;
|
const { data } = event;
|
||||||
@@ -158,8 +154,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
// 发送就绪通知
|
// 先加载后台白名单,再发送 READY,避免父站立即回 Token 时被本端误拒。
|
||||||
notifyReady();
|
void loadIframeAllowedOrigins().finally(() => {
|
||||||
|
notifyReady();
|
||||||
|
});
|
||||||
|
|
||||||
// 定期发送心跳
|
// 定期发送心跳
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { useCallback, useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
|
import {
|
||||||
|
isIframeOriginAllowed,
|
||||||
|
loadIframeAllowedOrigins,
|
||||||
|
} from "@/lib/iframe-origins";
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟
|
||||||
@@ -116,21 +120,15 @@ export function useTokenRefresh(): {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent): void => {
|
void loadIframeAllowedOrigins();
|
||||||
// 安全检查:验证来源
|
|
||||||
const allowedOrigins = [
|
|
||||||
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
|
|
||||||
// 开发环境允许本地
|
|
||||||
"http://localhost:3800",
|
|
||||||
"http://127.0.0.1:3800",
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (
|
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
allowedOrigins.length > 0 &&
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
!allowedOrigins.includes(event.origin)
|
await loadIframeAllowedOrigins();
|
||||||
) {
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
|
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = event;
|
const { data } = event;
|
||||||
|
|||||||
@@ -18,10 +18,37 @@ const ALLOWED_PARENT_ORIGINS: string[] = [
|
|||||||
// 生产环境应从环境变量读取
|
// 生产环境应从环境变量读取
|
||||||
].filter((o): o is string => Boolean(o));
|
].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 指令字符串
|
* 生成 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[]> = {
|
const directives: Record<string, string[]> = {
|
||||||
// 默认只允许同源
|
// 默认只允许同源
|
||||||
"default-src": ["'self'"],
|
"default-src": ["'self'"],
|
||||||
@@ -42,7 +69,7 @@ export function generateCSP(): string {
|
|||||||
"connect-src": [
|
"connect-src": [
|
||||||
"'self'",
|
"'self'",
|
||||||
process.env.NEXT_PUBLIC_API_URL || "",
|
process.env.NEXT_PUBLIC_API_URL || "",
|
||||||
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL || "",
|
process.env.API_BASE_URL || "",
|
||||||
// WebSocket 连接
|
// WebSocket 连接
|
||||||
"ws:",
|
"ws:",
|
||||||
"wss:",
|
"wss:",
|
||||||
@@ -55,10 +82,10 @@ export function generateCSP(): string {
|
|||||||
"object-src": ["'none'"],
|
"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'"],
|
"form-action": ["'self'"],
|
||||||
@@ -88,11 +115,7 @@ export function isAllowedParent(parentOrigin: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* 安全头配置(用于 next.config.ts)
|
* 安全头配置(用于 next.config.ts)
|
||||||
*/
|
*/
|
||||||
export const securityHeaders = [
|
export const nonCspSecurityHeaders = [
|
||||||
{
|
|
||||||
key: "Content-Security-Policy",
|
|
||||||
value: generateCSP(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "X-Content-Type-Options",
|
key: "X-Content-Type-Options",
|
||||||
value: "nosniff",
|
value: "nosniff",
|
||||||
@@ -106,3 +129,11 @@ export const securityHeaders = [
|
|||||||
value: "camera=(), microphone=(), geolocation=()",
|
value: "camera=(), microphone=(), geolocation=()",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const securityHeaders = [
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: generateCSP(),
|
||||||
|
},
|
||||||
|
...nonCspSecurityHeaders,
|
||||||
|
];
|
||||||
|
|||||||
78
src/lib/iframe-origins.ts
Normal file
78
src/lib/iframe-origins.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -14,13 +14,12 @@ import {
|
|||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
|
|
||||||
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **第一层**:只挂 `baseURL` / `timeout` / 默认 `Accept`。业务解析、toast 都不在这里。
|
* **第一层**:只挂 `baseURL` / `timeout` / 默认 `Accept`。业务解析、toast 都不在这里。
|
||||||
*/
|
*/
|
||||||
export const lotteryHttp = axios.create({
|
export const lotteryHttp = axios.create({
|
||||||
baseURL: baseURL && baseURL !== "" ? baseURL : undefined,
|
// 统一走 Next 同源 /api 代理,由 next.config.ts 的 API_BASE_URL 转发到后端。
|
||||||
|
baseURL: "/api",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user