From 8ef2cbe68e330d41870a3751d36e44691d736a86 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sat, 21 Feb 2026 18:55:10 +0100 Subject: [PATCH] v1.0.4: Security hardening - rate limiting, middleware, HSTS, password strength, anti-enumeration --- next.config.js | 12 ++ package.json | 2 +- src/app/api/auth/change-password/route.ts | 11 +- src/app/api/auth/delete-account/route.ts | 5 + src/app/api/auth/forgot-password/route.ts | 5 + src/app/api/auth/login/route.ts | 5 + src/app/api/auth/register/route.ts | 7 +- src/app/api/auth/resend-verification/route.ts | 5 + src/app/api/auth/reset-password/route.ts | 9 +- src/app/api/contact/route.ts | 5 + src/app/register/page.tsx | 6 +- src/app/reset-password/page.tsx | 6 +- src/lib/auth.ts | 4 +- src/lib/rate-limit.ts | 107 ++++++++++++++++ src/middleware.ts | 114 ++++++++++++++++++ 15 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 src/lib/rate-limit.ts create mode 100644 src/middleware.ts diff --git a/next.config.js b/next.config.js index be8d2fd..4247713 100644 --- a/next.config.js +++ b/next.config.js @@ -31,6 +31,18 @@ const nextConfig = { key: 'Cross-Origin-Opener-Policy', value: 'same-origin', }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { + key: 'X-DNS-Prefetch-Control', + value: 'on', + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block', + }, { key: 'Content-Security-Policy', value: [ diff --git a/package.json b/package.json index 74d53a5..9ef73d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.0.3", + "version": "1.0.4", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index 52c93f0..005ba89 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { prisma } from '@/lib/db' import bcrypt from 'bcryptjs' +import { rateLimit, getClientIp, rateLimitResponse } from '@/lib/rate-limit' + +const changePwLimiter = rateLimit({ id: 'change-pw', max: 5, windowSeconds: 60 * 15 }) export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = changePwLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const user = await getSession() if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) @@ -14,8 +21,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 }) } - if (newPassword.length < 6) { - return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 }) + if (newPassword.length < 8) { + return NextResponse.json({ error: 'Neues Kennwort muss mindestens 8 Zeichen lang sein' }, { status: 400 }) } const dbUser = await (prisma as any).user.findUnique({ diff --git a/src/app/api/auth/delete-account/route.ts b/src/app/api/auth/delete-account/route.ts index 8987b20..087d49f 100644 --- a/src/app/api/auth/delete-account/route.ts +++ b/src/app/api/auth/delete-account/route.ts @@ -3,10 +3,15 @@ import { prisma } from '@/lib/db' import { getSession } from '@/lib/auth' import bcrypt from 'bcryptjs' import { cookies } from 'next/headers' +import { deleteAccountLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' // POST: User deletes their own account export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = deleteAccountLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const session = await getSession() if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 018bcf8..0e513d1 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { randomBytes } from 'crypto' import { sendEmail, getSmtpConfig } from '@/lib/email' +import { forgotPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = forgotPasswordLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const { email } = await req.json() if (!email) { return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 }) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 88e5eb5..a26bcca 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,9 +3,14 @@ import { cookies } from 'next/headers' import { login, createToken } from '@/lib/auth' import { loginSchema } from '@/lib/validations' import { prisma } from '@/lib/db' +import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' export async function POST(request: NextRequest) { try { + const ip = getClientIp(request) + const rl = loginLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const body = await request.json() const validated = loginSchema.safeParse(body) diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index f2fd561..a3a366f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -4,16 +4,21 @@ import { hashPassword } from '@/lib/auth' import { sendEmail } from '@/lib/email' import { randomBytes } from 'crypto' import { z } from 'zod' +import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' const registerSchema = z.object({ organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200), name: z.string().min(2, 'Name zu kurz').max(200), email: z.string().email('Ungültige E-Mail-Adresse'), - password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'), + password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'), }) export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = registerLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const body = await req.json() const data = registerSchema.parse(body) diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts index ef40eb7..533e99c 100644 --- a/src/app/api/auth/resend-verification/route.ts +++ b/src/app/api/auth/resend-verification/route.ts @@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { sendEmail } from '@/lib/email' import { randomBytes } from 'crypto' +import { resendVerificationLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = resendVerificationLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const { email } = await req.json() if (!email) { diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 728bba2..3714a51 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,16 +1,21 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { hashPassword } from '@/lib/auth' +import { resetPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = resetPasswordLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const { token, password } = await req.json() if (!token || !password) { return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 }) } - if (password.length < 6) { - return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 }) + if (password.length < 8) { + return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' }, { status: 400 }) } const user = await (prisma as any).user.findFirst({ diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index b970cc6..800549e 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { sendEmail, getSmtpConfig } from '@/lib/email' import { z } from 'zod' +import { contactLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' const contactSchema = z.object({ name: z.string().min(1).max(200), @@ -23,6 +24,10 @@ async function getContactEmail(): Promise { export async function POST(req: NextRequest) { try { + const ip = getClientIp(req) + const rl = contactLimiter.check(ip) + if (!rl.success) return rateLimitResponse(rl.resetAt) + const body = await req.json() const data = contactSchema.parse(body) diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index b9ae451..e090186 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -30,8 +30,8 @@ export default function RegisterPage() { return } - if (password.length < 6) { - toast({ title: 'Passwort muss mindestens 6 Zeichen haben', variant: 'destructive' }) + if (password.length < 8) { + toast({ title: 'Passwort muss mindestens 8 Zeichen haben', variant: 'destructive' }) return } @@ -163,7 +163,7 @@ export default function RegisterPage() { setPassword(e.target.value)} required diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx index b5a7c17..617b411 100644 --- a/src/app/reset-password/page.tsx +++ b/src/app/reset-password/page.tsx @@ -32,8 +32,8 @@ function ResetPasswordForm() { e.preventDefault() setError('') - if (password.length < 6) { - setError('Passwort muss mindestens 6 Zeichen lang sein.') + if (password.length < 8) { + setError('Passwort muss mindestens 8 Zeichen lang sein.') return } if (password !== confirmPassword) { @@ -108,7 +108,7 @@ function ResetPasswordForm() { setPassword(e.target.value)} required diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f9d8f11..b68771c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -64,12 +64,12 @@ export async function login( }) as any) if (!user) { - return { success: false, error: 'Benutzer nicht gefunden' } + return { success: false, error: 'E-Mail oder Passwort falsch' } } const isValidPassword = await bcrypt.compare(password, user.password) if (!isValidPassword) { - return { success: false, error: 'Ungültiges Passwort' } + return { success: false, error: 'E-Mail oder Passwort falsch' } } // Track email verification status (allow login regardless) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..149a4d2 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,107 @@ +// In-memory rate limiter for API endpoints +// Tracks request counts per IP within sliding windows + +interface RateLimitEntry { + count: number + resetAt: number +} + +const stores = new Map>() + +interface RateLimitConfig { + /** Unique identifier for this limiter (e.g. 'login', 'register') */ + id: string + /** Maximum requests allowed within the window */ + max: number + /** Window duration in seconds */ + windowSeconds: number +} + +interface RateLimitResult { + success: boolean + remaining: number + resetAt: number +} + +function getStore(id: string): Map { + if (!stores.has(id)) { + stores.set(id, new Map()) + } + return stores.get(id)! +} + +// Periodic cleanup of expired entries (every 5 minutes) +setInterval(() => { + const now = Date.now() + for (const [, store] of stores) { + for (const [key, entry] of store) { + if (now > entry.resetAt) { + store.delete(key) + } + } + } +}, 5 * 60 * 1000) + +export function rateLimit(config: RateLimitConfig) { + const store = getStore(config.id) + + return { + check(ip: string): RateLimitResult { + const now = Date.now() + const key = ip + const entry = store.get(key) + + // No entry or expired → fresh window + if (!entry || now > entry.resetAt) { + store.set(key, { + count: 1, + resetAt: now + config.windowSeconds * 1000, + }) + return { success: true, remaining: config.max - 1, resetAt: now + config.windowSeconds * 1000 } + } + + // Within window + entry.count++ + if (entry.count > config.max) { + return { success: false, remaining: 0, resetAt: entry.resetAt } + } + + return { success: true, remaining: config.max - entry.count, resetAt: entry.resetAt } + }, + } +} + +// Pre-configured limiters for different endpoints +export const loginLimiter = rateLimit({ id: 'login', max: 5, windowSeconds: 60 * 15 }) // 5 attempts per 15 min +export const registerLimiter = rateLimit({ id: 'register', max: 3, windowSeconds: 60 * 60 }) // 3 per hour +export const forgotPasswordLimiter = rateLimit({ id: 'forgot-pw', max: 3, windowSeconds: 60 * 15 }) // 3 per 15 min +export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3, windowSeconds: 60 * 15 }) +export const contactLimiter = rateLimit({ id: 'contact', max: 5, windowSeconds: 60 * 60 }) // 5 per hour +export const deleteAccountLimiter = rateLimit({ id: 'delete-acct', max: 3, windowSeconds: 60 * 15 }) +export const resetPasswordLimiter = rateLimit({ id: 'reset-pw', max: 5, windowSeconds: 60 * 15 }) + +/** Extract client IP from request headers */ +export function getClientIp(req: Request): string { + const forwarded = req.headers.get('x-forwarded-for') + if (forwarded) { + return forwarded.split(',')[0].trim() + } + const realIp = req.headers.get('x-real-ip') + if (realIp) return realIp + return '127.0.0.1' +} + +/** Helper: create a 429 response with retry-after header */ +export function rateLimitResponse(resetAt: number) { + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000) + return new Response( + JSON.stringify({ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(retryAfter), + }, + } + ) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..27c89e2 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server' +import { jwtVerify } from 'jose' + +const JWT_SECRET = new TextEncoder().encode( + process.env.NEXTAUTH_SECRET || 'dev-only-fallback-do-not-use-in-production' +) + +// Routes that require authentication +const PROTECTED_ROUTES = ['/app', '/settings', '/admin'] + +// Routes that should redirect to /app if already logged in +const AUTH_ROUTES = ['/login', '/register'] + +// API routes that are public (no auth needed) +const PUBLIC_API_PREFIXES = [ + '/api/auth/login', + '/api/auth/register', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/verify-email', + '/api/auth/resend-verification', + '/api/auth/logout', + '/api/contact', + '/api/demo', + '/api/donate', + '/api/rapports/', + '/api/tenants/by-slug/', +] + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + const token = req.cookies.get('auth-token')?.value + + // Verify token if present + let user: any = null + if (token) { + try { + const { payload } = await jwtVerify(token, JWT_SECRET) + user = payload.user + } catch { + // Invalid/expired token — clear it + const response = NextResponse.redirect(new URL('/login', req.url)) + response.cookies.delete('auth-token') + // Only redirect if accessing protected routes + if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) { + return response + } + } + } + + // Protected routes: redirect to login if not authenticated + if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) { + if (!user) { + const loginUrl = new URL('/login', req.url) + loginUrl.searchParams.set('redirect', pathname) + return NextResponse.redirect(loginUrl) + } + + // Admin routes: only SERVER_ADMIN and TENANT_ADMIN + if (pathname.startsWith('/admin') && user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') { + return NextResponse.redirect(new URL('/app', req.url)) + } + } + + // Auth routes: redirect to /app if already logged in + if (AUTH_ROUTES.some(r => pathname.startsWith(r))) { + if (user) { + return NextResponse.redirect(new URL('/app', req.url)) + } + } + + // API routes: check auth for non-public endpoints + if (pathname.startsWith('/api/') && !PUBLIC_API_PREFIXES.some(p => pathname.startsWith(p))) { + if (!user) { + // Allow /api/auth/me to return null (used for auth check) + if (pathname === '/api/auth/me') { + return NextResponse.next() + } + // Allow /api/icons GET (public for symbol loading) + if (pathname === '/api/icons' && req.method === 'GET') { + return NextResponse.next() + } + return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + } + } + + // Security: block common attack paths + if ( + pathname.includes('..') || + pathname.includes('.env') || + pathname.includes('wp-admin') || + pathname.includes('wp-login') || + pathname.includes('.php') || + pathname.includes('xmlrpc') || + pathname.match(/\.(sql|bak|config|log|ini)$/i) + ) { + return new NextResponse(null, { status: 404 }) + } + + return NextResponse.next() +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization) + * - favicon.ico, sitemap.xml, robots.txt + * - public files (images, sw.js, etc.) + */ + '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|icons/|sw.js|manifest.json|opengraph-image).*)', + ], +}