import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; // --------------------------------------------------------------------------- // Simple in-memory rate limiter (resets on cold start / per-instance) // For production use a Redis-backed store via @upstash/ratelimit or similar. // --------------------------------------------------------------------------- interface RateLimitEntry { count: number; resetAt: number; } const rateLimitMap = new Map(); const RATE_LIMIT_MAX = 100; // requests per window const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute function checkRateLimit(ip: string): boolean { const now = Date.now(); const entry = rateLimitMap.get(ip); if (!entry || now > entry.resetAt) { rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return true; // allowed } if (entry.count >= RATE_LIMIT_MAX) { return false; // blocked } entry.count++; return true; // allowed } // --------------------------------------------------------------------------- // Paths that bypass middleware entirely // --------------------------------------------------------------------------- const PUBLIC_PATH_PREFIXES = [ "/api/auth", "/_next", "/favicon.ico", "/public", ]; const STATIC_EXTENSIONS = /\.(png|jpe?g|gif|svg|ico|webp|woff2?|ttf|otf|eot|css|js\.map)$/i; function isPublicPath(pathname: string): boolean { if (STATIC_EXTENSIONS.test(pathname)) return true; return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } // --------------------------------------------------------------------------- // Security headers applied to every response // --------------------------------------------------------------------------- function buildCSP(nonce: string): string { return [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}'`, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net", "connect-src 'self' ws: wss:", "frame-src 'self'", "frame-ancestors 'self'", "worker-src 'self' blob:", "media-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", ].join("; "); } function applySecurityHeaders(response: NextResponse, nonce: string): void { const csp = buildCSP(nonce); response.headers.set("Content-Security-Policy", csp); response.headers.set("X-Frame-Options", "SAMEORIGIN"); response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), browsing-topics=()" ); // Pass the nonce to pages so they can inject it into inline scripts response.headers.set("x-nonce", nonce); } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; // Skip middleware for public/static paths if (isPublicPath(pathname)) { return NextResponse.next(); } // Generate a fresh nonce for this request const nonce = crypto.randomBytes(16).toString("base64"); // --------------------------------------------------------------------------- // Rate limiting for API routes // --------------------------------------------------------------------------- if (pathname.startsWith("/api/")) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "unknown"; if (!checkRateLimit(ip)) { return new NextResponse( JSON.stringify({ error: "Too many requests. Please slow down." }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": "60", "X-RateLimit-Limit": String(RATE_LIMIT_MAX), "X-RateLimit-Window": "60", }, } ); } } // --------------------------------------------------------------------------- // Auth guard for dashboard routes // --------------------------------------------------------------------------- if (pathname.startsWith("/(dashboard)") || isDashboardRoute(pathname)) { try { // Dynamically import auth to avoid circular deps at module init const { auth } = await import("@/lib/auth/index"); const session = await auth.api.getSession({ headers: request.headers, }); if (!session) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("callbackUrl", pathname); const redirectResponse = NextResponse.redirect(loginUrl); applySecurityHeaders(redirectResponse, nonce); return redirectResponse; } } catch { // If auth module is not yet available (during initial setup), allow through // In production this should not happen console.error("[middleware] Auth check failed – denying access"); const loginUrl = new URL("/login", request.url); const redirectResponse = NextResponse.redirect(loginUrl); applySecurityHeaders(redirectResponse, nonce); return redirectResponse; } } // --------------------------------------------------------------------------- // Allow the request through, attach security headers // --------------------------------------------------------------------------- const response = NextResponse.next(); applySecurityHeaders(response, nonce); return response; } /** * Checks whether the pathname maps to a dashboard route. * Next.js route groups (dashboard) don't appear in the URL, so we protect * all non-auth, non-api routes that would render inside the dashboard layout. */ function isDashboardRoute(pathname: string): boolean { const AUTH_ROUTES = ["/login", "/register", "/forgot-password", "/reset-password", "/verify"]; if (AUTH_ROUTES.some((r) => pathname.startsWith(r))) return false; if (pathname.startsWith("/api/")) return false; if (pathname === "/") return true; const DASHBOARD_SEGMENTS = [ "/dashboard", "/console", "/monitoring", "/scheduler", "/players", "/map", "/plugins", "/files", "/backups", "/settings", "/updates", "/team", "/audit", ]; return DASHBOARD_SEGMENTS.some((seg) => pathname.startsWith(seg)); } export const config = { matcher: [ /* * Match all request paths except: * - _next/static (static files) * - _next/image (image optimisation) * - favicon.ico */ "/((?!_next/static|_next/image|favicon.ico).*)", ], };