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:
ralksta
2026-03-13 11:41:56 +01:00
parent 3b657b625c
commit d64a7239d1
3 changed files with 42 additions and 3 deletions

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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 };