Initial push
This commit is contained in:
201
proxy.ts
Normal file
201
proxy.ts
Normal 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).*)",
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user