import { type NextRequest, NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist"; import { checkRateLimit, checkRateLimitByIdentifier, rateLimiters, } from "@/lib/security/rate-limiter"; import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor"; function isHighFrequencyReadApi(pathname: string, method: string) { if (method !== "GET") return false; if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true; return [ "/api/models", "/api/canvas", "/api/canvas-skill-tags", "/api/nav-links", "/api/site-settings", "/api/messages", "/api/history", "/api/vote", "/api/video-tasks", "/api/user/image-stats", "/api/tutorial-videos", "/api/user/membership-summary", "/api/user/account-center", ].includes(pathname); } export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const isSecure = process.env.AUTH_URL?.startsWith("https://") || request.nextUrl.protocol === "https:"; let cachedToken: Awaited> | null | undefined; const resolveToken = async () => { if (cachedToken !== undefined) { return cachedToken; } cachedToken = await getToken({ req: request, secret: process.env.AUTH_SECRET, secureCookie: isSecure, }); return cachedToken; }; // 🛡️ 安全检查1:阻止黑名单IP const clientIP = getClientIP(request); if (isBlockedIP(clientIP)) { console.warn(`[Security] Blocked request from blacklisted IP: ${clientIP}`, { pathname, method: request.method, userAgent: request.headers.get("user-agent"), }); return new NextResponse("Forbidden", { status: 403 }); } // 🛡️ 安全检查2:检测可疑请求模式 const suspiciousCheck = isSuspiciousRequest(request); if (suspiciousCheck.suspicious) { logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern"); // 对于可疑请求,记录但不立即阻止(避免误报) // 可以在这里添加更严格的检查 } // 浏览器会自动拉取 favicon,不应参与通用 API 限流。 if (pathname === "/api/favicon") { return NextResponse.next(); } // three.js KTX2/Basis transcoder assets are public runtime dependencies used // by the in-browser 3D viewer. Redirecting them to login breaks texture decode // and leaves generated 3D models as white clay renders. if (pathname.startsWith("/three/basis/")) { return NextResponse.next(); } // Site fonts are public static assets referenced by global CSS. Redirecting // them to login prevents the browser from loading the configured typeface. if (pathname.startsWith("/fonts/")) { return NextResponse.next(); } // 🛡️ 安全检查3:速率限制 // 对API路径应用速率限制 if (pathname === "/api/chat") { const chatRateLimit = checkRateLimit(request, rateLimiters.chat); if (!chatRateLimit.allowed) { console.warn(`[Security] Chat rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(chatRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ); } } else if (pathname === "/api/image-proxy" && request.method === "GET") { const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy); if (!imageProxyRateLimit.allowed) { const retryAfter = Math.max(1, Math.ceil((imageProxyRateLimit.resetTime - Date.now()) / 1000)); console.warn(`[Security] Image proxy rate limit exceeded for IP: ${clientIP}`, { pathname, method: request.method, resetTime: new Date(imageProxyRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many image proxy requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ); } } else if ( (pathname === "/api/files/upload" || pathname === "/api/files/direct-upload") && request.method === "POST" ) { const token = await resolveToken(); const userId = typeof token?.sub === "string" && token.sub.length > 0 ? token.sub : "anonymous"; const uploadRateLimit = checkRateLimitByIdentifier( `${userId}:${request.method}:${pathname}`, rateLimiters.upload ); if (!uploadRateLimit.allowed) { const retryAfter = Math.max(1, Math.ceil((uploadRateLimit.resetTime - Date.now()) / 1000)); console.warn(`[Security] Upload rate limit exceeded for IP: ${clientIP}`, { pathname, method: request.method, userId, resetTime: new Date(uploadRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many upload requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ); } } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(canvasRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ); } } else if (pathname === "/api/canvas/auto-title" && request.method === "POST") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { console.warn(`[Security] Canvas auto-title rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(canvasRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000), }, { status: 429 } ); } } else if ( pathname.startsWith("/api/realtime/") || pathname.startsWith("/api/fal/proxy") ) { const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime); if (!realtimeRateLimit.allowed) { console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(realtimeRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ); } } else if (isHighFrequencyReadApi(pathname, request.method)) { const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling); if (!pollingRateLimit.allowed) { console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(pollingRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many polling requests", retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000), }, { status: 429 } ); } } else if (pathname.startsWith("/api/admin/")) { const token = await resolveToken(); const adminIdentifier = typeof token?.sub === "string" && token.sub.length > 0 ? `admin:${token.sub}` : `admin-ip:${clientIP}`; const adminRateLimit = checkRateLimitByIdentifier( adminIdentifier, rateLimiters.admin ); if (!adminRateLimit.allowed) { const retryAfter = Math.max( 1, Math.ceil((adminRateLimit.resetTime - Date.now()) / 1000) ); console.warn(`[Security] Admin rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(adminRateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many admin requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ); } } else if (pathname.startsWith("/api/")) { const rateLimit = checkRateLimit(request, rateLimiters.api); if (!rateLimit.allowed) { console.warn(`[Security] Rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(rateLimit.resetTime).toISOString(), }); return NextResponse.json( { error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ); } } // 🛡️ 安全检查4:认证相关API的严格速率限制 if (pathname.startsWith("/api/auth/")) { const rateLimit = checkRateLimit(request, rateLimiters.auth); if (!rateLimit.allowed) { console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`); return NextResponse.json( { error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ); } } // 允许访问 ping 端点 if (pathname.startsWith("/ping")) { return NextResponse.next(); } if (pathname === "/classic" || pathname.startsWith("/chat/")) { return NextResponse.redirect(new URL("/", request.url)); } // 允许访问认证相关 API if (pathname.startsWith("/api/auth")) { return NextResponse.next(); } // 允许访问公开的网站设置 API(登录页面需要) if (pathname === "/api/site-settings") { return NextResponse.next(); } // 允许定时任务接口由服务端调用(具体鉴权由接口内部 CRON_SECRET 负责) if (pathname.startsWith("/api/jobs/")) { return NextResponse.next(); } // 允许公开访问动态 favicon(浏览器标签图标) if (pathname === "/api/favicon") { return NextResponse.next(); } // 允许访问公开的认证配置(登录弹窗需要) if (pathname === "/api/auth-settings") { return NextResponse.next(); } // 允许匿名访问新版首页灵感数据 if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) { return NextResponse.next(); } // 允许匿名访问新版首页的轻量渐进配置接口 if ( request.method === "GET" && ["/api/nav-links", "/api/tutorial-videos", "/api/home-animation"].includes(pathname) ) { return NextResponse.next(); } // 允许访问钉钉回调页面(public 文件夹中的文件) if (pathname === "/dingtalk-callback.html") { return NextResponse.next(); } // 允许 DuluPay 支付回调(服务端回调无 session cookie) if (pathname.startsWith("/api/payment/")) { return NextResponse.next(); } // 允许 World Labs key 池导入接口由外部脚本调用(具体鉴权由接口内 token 负责) if (pathname === "/api/worldlabs/key-pool/import") { return NextResponse.next(); } const requestHostname = request.nextUrl.hostname.toLowerCase(); const isLoopbackRequest = requestHostname === "localhost" || requestHostname === "127.0.0.1" || requestHostname === "::1"; if ( pathname === "/api/files/upload" && request.method === "POST" && isLoopbackRequest && process.env.USE_REMOTE_STORAGE_IN_DEV !== "true" ) { // The route still limits anonymous loopback uploads to explicit canvas purposes. return NextResponse.next(); } if ( (pathname === "/api/canvas/audio-source" || pathname === "/api/canvas/video-source") && request.method === "GET" && isLoopbackRequest ) { // The route validates the remote host and blocks private-network targets. return NextResponse.next(); } // 允许第三方生图 API 由外部系统直接调用(具体鉴权由接口内 API Key 负责) if (pathname.startsWith("/api/external/image/")) { return NextResponse.next(); } const token = await resolveToken(); const isPublicPage = pathname === "/" || pathname === "/login" || pathname === "/register" || pathname === "/particle-preview" || pathname === "/riji" || pathname.startsWith("/riji/") || pathname.startsWith("/worlds/share/"); // 未登录用户允许访问公开页面,其余需要登录 if (!token && !isPublicPage) { // 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页 if (pathname.startsWith("/api/")) { return NextResponse.json( { error: "未授权,请先登录" }, { status: 401 } ); } // 对于页面路由(GET 请求),重定向到登录页面 const externalBase = process.env.AUTH_URL ?? `${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`; const loginUrl = new URL("/login", externalBase); // 保存当前路径作为回调 URL loginUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: [ /* * 匹配所有请求路径,除了: * - api/auth (NextAuth.js) * - _next/static (静态文件) * - _next/image (图片优化文件) * - favicon.ico (favicon文件) * - public 文件夹中的文件 */ "/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|js|wasm|woff2?|ttf|otf|eot)$).*)", ], };