Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

201
proxy.ts Normal file
View File

@@ -0,0 +1,201 @@
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
// ---------------------------------------------------------------------------
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).*)",
],
};