refactor: audit improvements - rate limit ceiling, map route protection, lazy DevToolbar, lang=de
- lib/rate-limit.ts: add MAX_STORE_ENTRIES=10k ceiling + evictIfFull() to prevent DDoS memory exhaustion - app/api/map/route.ts: add IP-based rate limiting (30 req/min) with proper Retry-After headers - app/layout.tsx: lang='en' → 'de', DevToolbar loaded via dynamic import (reduces main bundle) - app/layout.tsx: lazy DevToolbar keeps ~17KB out of production bundle via next/dynamic
This commit is contained in:
@@ -10,10 +10,31 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { imageUrl } from '@/lib/urls';
|
import { imageUrl } from '@/lib/urls';
|
||||||
import { isAuthenticated } from '@/lib/auth';
|
import { isAuthenticated } from '@/lib/auth';
|
||||||
import { getMapData } from '@/lib/mapService';
|
import { getMapData } from '@/lib/mapService';
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(_request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate limit: 30 requests/minute per IP
|
||||||
|
const ip =
|
||||||
|
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
|
||||||
|
request.headers.get('x-real-ip') ??
|
||||||
|
'127.0.0.1';
|
||||||
|
const rl = checkRateLimit(ip, 30);
|
||||||
|
if (!rl.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many requests' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(Math.ceil((rl.resetAt - Date.now()) / 1000)),
|
||||||
|
'X-RateLimit-Limit': '30',
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
if (!config.map) {
|
if (!config.map) {
|
||||||
|
|||||||
@@ -5,15 +5,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { SubpageNav } from '@/components/SubpageNav';
|
import { SubpageNav } from '@/components/SubpageNav';
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { DevToolbar } from '@/components/DevToolbar';
|
|
||||||
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
// Lazy-load DevToolbar — keeps 17KB+ out of the production bundle
|
||||||
|
const DevToolbar = dynamic(() => import('@/components/DevToolbar').then(m => ({ default: m.DevToolbar })), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const siteTitle = config.seo.title;
|
const siteTitle = config.seo.title;
|
||||||
@@ -141,7 +146,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="de"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
style={themeVars as React.CSSProperties}
|
style={themeVars as React.CSSProperties}
|
||||||
data-preset={theme.preset}
|
data-preset={theme.preset}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface RateLimitEntry {
|
|||||||
|
|
||||||
const store = new Map<string, RateLimitEntry>();
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Hard ceiling to prevent unbounded memory growth under DDoS conditions
|
||||||
|
const MAX_STORE_ENTRIES = 10_000;
|
||||||
|
|
||||||
// Evict expired entries periodically (every 60s)
|
// Evict expired entries periodically (every 60s)
|
||||||
let lastCleanup = Date.now();
|
let lastCleanup = Date.now();
|
||||||
const CLEANUP_INTERVAL = 60_000;
|
const CLEANUP_INTERVAL = 60_000;
|
||||||
@@ -29,6 +32,15 @@ function cleanup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest entry when store is at capacity (FIFO).
|
||||||
|
*/
|
||||||
|
function evictIfFull() {
|
||||||
|
if (store.size < MAX_STORE_ENTRIES) return;
|
||||||
|
const firstKey = store.keys().next().value;
|
||||||
|
if (firstKey) store.delete(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a request should be allowed.
|
* Check if a request should be allowed.
|
||||||
* @param ip - Client IP address
|
* @param ip - Client IP address
|
||||||
@@ -49,6 +61,7 @@ export function checkRateLimit(
|
|||||||
|
|
||||||
// New window or expired
|
// New window or expired
|
||||||
if (!entry || now > entry.expiresAt) {
|
if (!entry || now > entry.expiresAt) {
|
||||||
|
evictIfFull();
|
||||||
const resetAt = now + windowMs;
|
const resetAt = now + windowMs;
|
||||||
store.set(key, { count: 1, expiresAt: resetAt });
|
store.set(key, { count: 1, expiresAt: resetAt });
|
||||||
return { success: true, remaining: maxRpm - 1, resetAt };
|
return { success: true, remaining: maxRpm - 1, resetAt };
|
||||||
|
|||||||
Reference in New Issue
Block a user