212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
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<string, RateLimitEntry>();
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
const isDev = process.env.NODE_ENV !== "production";
|
||
|
||
function buildCSP(nonce: string): string {
|
||
// In dev, Next.js hot-reload and some auth libs require 'unsafe-eval'.
|
||
// In production we restrict to 'wasm-unsafe-eval' (WebAssembly only).
|
||
const evalDirective = isDev ? "'unsafe-eval'" : "'wasm-unsafe-eval'";
|
||
|
||
return [
|
||
"default-src 'self'",
|
||
`script-src 'self' 'nonce-${nonce}' ${evalDirective} 'unsafe-inline'`,
|
||
"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",
|
||
// In dev, include http://localhost:* explicitly so absolute-URL fetches
|
||
// (e.g. from Better Auth client) aren't blocked by a strict 'self' check.
|
||
isDev
|
||
? "connect-src 'self' http://localhost:* ws://localhost:* wss://localhost:* ws: wss:"
|
||
: "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).*)",
|
||
],
|
||
};
|