diff --git a/apps/next/src/lib/proxy/ban-sus-ips.ts b/apps/next/src/lib/proxy/ban-sus-ips.ts index 72f6e98..762a9d3 100644 --- a/apps/next/src/lib/proxy/ban-sus-ips.ts +++ b/apps/next/src/lib/proxy/ban-sus-ips.ts @@ -4,7 +4,8 @@ import { NextResponse } from 'next/server'; // In-memory stores for tracking IPs (use Redis in production) const ipAttempts = new Map(); const ip404Attempts = new Map(); -const bannedIPs = new Set(); +// Map of ip -> ban expiry timestamp. Avoids setTimeout closures leaking on hot reload. +const bannedIPs = new Map(); // Suspicious patterns that indicate malicious activity const MALICIOUS_PATTERNS = [ @@ -72,6 +73,36 @@ const BAN_DURATION = 30 * 60 * 1000; // 30 minutes const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes const MAX_404_ATTEMPTS = 10; // Max 404s before ban +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes + +// Lazily purge stale entries so Maps don't grow without bound. +// Called on every request but only iterates Maps every CLEANUP_INTERVAL. +const cleanupStaleMaps = () => { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL) return; + lastCleanup = now; + for (const [ip, data] of ipAttempts.entries()) { + if (now - data.lastAttempt > RATE_LIMIT_WINDOW) ipAttempts.delete(ip); + } + for (const [ip, data] of ip404Attempts.entries()) { + if (now - data.lastAttempt > RATE_404_WINDOW) ip404Attempts.delete(ip); + } + for (const [ip, expiry] of bannedIPs.entries()) { + if (now > expiry) bannedIPs.delete(ip); + } +}; + +const isIPBanned = (ip: string): boolean => { + const expiry = bannedIPs.get(ip); + if (expiry === undefined) return false; + if (Date.now() > expiry) { + bannedIPs.delete(ip); + return false; + } + return true; +}; + const getClientIP = (request: NextRequest): string => { const forwarded = request.headers.get('x-forwarded-for'); const realIP = request.headers.get('x-real-ip'); @@ -104,13 +135,8 @@ const updateIPAttempts = (ip: string): boolean => { attempts.lastAttempt = now; if (attempts.count > MAX_ATTEMPTS) { - bannedIPs.add(ip); + bannedIPs.set(ip, Date.now() + BAN_DURATION); ipAttempts.delete(ip); - - setTimeout(() => { - bannedIPs.delete(ip); - }, BAN_DURATION); - return true; } @@ -130,17 +156,13 @@ const update404Attempts = (ip: string): boolean => { attempts.lastAttempt = now; if (attempts.count > MAX_404_ATTEMPTS) { - bannedIPs.add(ip); + bannedIPs.set(ip, Date.now() + BAN_DURATION); ip404Attempts.delete(ip); console.log( `🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`, ); - setTimeout(() => { - bannedIPs.delete(ip); - }, BAN_DURATION); - return true; } @@ -148,12 +170,14 @@ const update404Attempts = (ip: string): boolean => { }; export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => { + cleanupStaleMaps(); + const { pathname } = request.nextUrl; const method = request.method; const ip = getClientIP(request); // Check if IP is already banned - if (bannedIPs.has(ip)) { + if (isIPBanned(ip)) { return new NextResponse('Access denied.', { status: 403 }); } @@ -183,7 +207,7 @@ export const handle404Response = ( ): NextResponse | null => { const ip = getClientIP(request); - if (bannedIPs.has(ip)) { + if (isIPBanned(ip)) { return new NextResponse('Access denied.', { status: 403 }); } diff --git a/apps/next/src/proxy.ts b/apps/next/src/proxy.ts index 0e729e0..03889b2 100644 --- a/apps/next/src/proxy.ts +++ b/apps/next/src/proxy.ts @@ -1,19 +1,36 @@ +import { convexAuthNextjsMiddleware } from "@convex-dev/auth/nextjs/server"; + +export default convexAuthNextjsMiddleware(); + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + '/((?!.*\\..*|_next).*)', + '/', + '/(api)(.*)', + ], +}; + +/** import { banSuspiciousIPs } from '@/lib/proxy/ban-sus-ips'; import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect, } from '@convex-dev/auth/nextjs/server'; - const isSignInPage = createRouteMatcher(['/sign-in']); -const isProtectedRoute = createRouteMatcher(['/profile', '/admin']); +const isProtectedRoute = createRouteMatcher([ + '/profile(.*)', + '/dashboard(.*)', + '/admin-panel(.*)', +]); export default convexAuthNextjsMiddleware( async (request, { convexAuth }) => { const banResponse = banSuspiciousIPs(request); if (banResponse) return banResponse; if (isSignInPage(request) && (await convexAuth.isAuthenticated())) { - return nextjsMiddlewareRedirect(request, '/'); + return nextjsMiddlewareRedirect(request, '/profile'); } if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) { return nextjsMiddlewareRedirect(request, '/sign-in'); @@ -23,8 +40,6 @@ export default convexAuthNextjsMiddleware( ); export const config = { - // The following matcher runs middleware on all routes - // except static assets. matcher: [ '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', '/((?!.*\\..*|_next).*)', @@ -32,3 +47,4 @@ export const config = { '/(api)(.*)', ], }; +**/