Files
CubeAdmin/proxy.ts
2026-03-08 17:01:36 +01:00

212 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).*)",
],
};