diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..6aabf68 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,89 @@ +name: Security Scanning + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + schedule: + # Wöchentlich Sonntag um 00:00 UTC + - cron: '0 0 * * 0' + +permissions: + contents: read + security-events: write + +jobs: + # Secret Scanning mit Gitleaks + secrets: + name: Secret Scanning + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + # Dependency Vulnerability Scanning + dependencies: + name: Dependency Audit + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run audit + run: pnpm audit --audit-level=high + continue-on-error: true + + - name: Check for known vulnerabilities + run: | + echo "## Dependency Audit Results" >> $GITHUB_STEP_SUMMARY + pnpm audit --json | jq -r '.advisories | to_entries[] | "- [\(.value.severity)] \(.value.module_name): \(.value.title)"' >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "No vulnerabilities found" >> $GITHUB_STEP_SUMMARY + + # CodeQL Analysis + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:javascript-typescript" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..fd5c03f --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,60 @@ +# Gitleaks Configuration +# https://github.com/gitleaks/gitleaks + +title = "Payload CMS Gitleaks Config" + +[extend] +# Extend the default gitleaks config +useDefault = true + +# Pfade die ignoriert werden sollen +[allowlist] +paths = [ + '''node_modules/''', + '''\.next/''', + '''dist/''', + '''coverage/''', + '''\.pnpm/''', + '''pnpm-lock\.yaml''', + '''package-lock\.json''', + '''\.env\.example''', + '''\.env\.sample''', + '''docs/.*\.md''', +] + +# Regexes die ignoriert werden sollen (für Test-Daten etc.) +regexes = [ + '''example\.com''', + '''test@test\.com''', + '''dummy''', + '''placeholder''', +] + +# Commits die ignoriert werden sollen +commits = [] + +# Zusätzliche Regeln +[[rules]] +id = "payload-secret" +description = "Payload Secret" +regex = '''PAYLOAD_SECRET\s*=\s*['\"]?[a-zA-Z0-9_-]{20,}['\"]?''' +tags = ["secret", "payload"] + +[[rules]] +id = "smtp-password" +description = "SMTP Password in config" +regex = '''smtp[_-]?pass(?:word)?\s*[:=]\s*['\"][^'\"]+['\"]''' +tags = ["secret", "smtp"] + +[[rules]] +id = "database-url" +description = "Database URL with credentials" +regex = '''(?i)(postgres|mysql|mongodb|redis)://[^:]+:[^@]+@''' +tags = ["secret", "database"] + +# Stopwords die einen Match verhindern +[[rules]] +id = "false-positive-example" +description = "Example values that are not secrets" +regex = '''(example|sample|dummy|placeholder|YOUR_[A-Z_]+_HERE)''' +allowlist = { regexes = ['''.*'''] } diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md index 5f613a1..552a9c1 100644 --- a/docs/anleitungen/TODO.md +++ b/docs/anleitungen/TODO.md @@ -226,16 +226,29 @@ - [ ] Sync-Script für Offsite-Backup #### Security Hardening -- [ ] **API-Schutz erweitern** - - [ ] Globales Rate-Limiting für alle öffentlichen Endpoints - - [ ] IP-Allowlist Option für `/api/send-email` - - [ ] CSRF-Schutz für Browser-basierte API-Calls -- [ ] **Sensitive Data Masking** - - [ ] Email-Error-Logs maskieren (keine Secrets in Admin UI) - - [ ] SMTP-Passwörter in Logs redacten -- [ ] **Secrets Scanning** - - [ ] Pre-commit Hook für Secret-Detection - - [ ] GitHub Secret Scanning aktivieren +- [x] **API-Schutz erweitern** (Erledigt: 07.12.2025) + - [x] Globales Rate-Limiting für alle öffentlichen Endpoints + - Zentraler Rate-Limiter Service (`src/lib/security/rate-limiter.ts`) + - Vordefinierte Limiter: publicApi (60/min), auth (5/15min), email (10/min), search (30/min), form (5/10min) + - Redis-Support für verteilte Systeme mit In-Memory-Fallback + - [x] IP-Allowlist Option für `/api/send-email` + - Konfiguration via `SEND_EMAIL_ALLOWED_IPS` env + - Unterstützt IPs, CIDRs und Wildcards + - Globale Blocklist via `BLOCKED_IPS` env + - [x] CSRF-Schutz für Browser-basierte API-Calls + - Double Submit Cookie Pattern + - Origin-Header-Validierung + - Token-Endpoint: `GET /api/csrf-token` +- [x] **Sensitive Data Masking** (Erledigt: 07.12.2025) + - [x] Zentraler Data-Masking-Service (`src/lib/security/data-masking.ts`) + - [x] Automatische Maskierung von Passwörtern, Tokens, API-Keys + - [x] Safe-Logger-Factory für konsistentes Logging + - [x] Rekursive Object-Maskierung für Audit-Logs +- [x] **Secrets Scanning** (Erledigt: 07.12.2025) + - [x] Pre-commit Hook für Secret-Detection (`scripts/detect-secrets.sh`) + - [x] GitHub Actions Workflow für Gitleaks und CodeQL + - [x] Gitleaks-Konfiguration (`.gitleaks.toml`) + - [x] Dependency Vulnerability Scanning ### Mittlere Priorität - Performance & Skalierung @@ -415,4 +428,4 @@ --- -*Letzte Aktualisierung: 07.12.2025 (Monitoring & Alerting implementiert)* +*Letzte Aktualisierung: 07.12.2025 (Security Hardening implementiert)* diff --git a/scripts/detect-secrets.sh b/scripts/detect-secrets.sh new file mode 100755 index 0000000..26c2e82 --- /dev/null +++ b/scripts/detect-secrets.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# +# Secret Detection Pre-Commit Hook +# +# Dieses Skript prüft staged Dateien auf potenzielle Secrets. +# Installation: ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit +# + +set -e + +# Farben für Output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🔍 Checking for secrets in staged files...${NC}" + +# Patterns für potenzielle Secrets +PATTERNS=( + # API Keys und Tokens + 'api[_-]?key\s*[:=]\s*["\x27][a-zA-Z0-9_-]{20,}["\x27]' + 'token\s*[:=]\s*["\x27][a-zA-Z0-9_-]{20,}["\x27]' + + # AWS + 'AKIA[0-9A-Z]{16}' + 'aws[_-]?secret[_-]?access[_-]?key' + + # Private Keys + '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----' + + # Passwörter in Code + 'password\s*[:=]\s*["\x27][^"\x27]{8,}["\x27]' + 'passwd\s*[:=]\s*["\x27][^"\x27]{8,}["\x27]' + + # SMTP Credentials + 'smtp[_-]?pass(word)?\s*[:=]\s*["\x27][^"\x27]+["\x27]' + + # Database URLs mit Passwörtern + '(postgres|mysql|mongodb)://[^:]+:[^@]+@' + + # JWT Tokens (vollständige) + 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' + + # Generic Secrets + 'secret\s*[:=]\s*["\x27][^"\x27]{16,}["\x27]' + + # Slack Webhooks + 'hooks\.slack\.com/services/T[A-Z0-9]{8}/B[A-Z0-9]{8}/[a-zA-Z0-9]{24}' + + # Discord Webhooks + 'discord(app)?\.com/api/webhooks/[0-9]+/[a-zA-Z0-9_-]+' + + # GitHub Tokens + 'ghp_[a-zA-Z0-9]{36}' + 'gho_[a-zA-Z0-9]{36}' + 'ghu_[a-zA-Z0-9]{36}' + 'ghs_[a-zA-Z0-9]{36}' + + # SendGrid + 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}' + + # Stripe + 'sk_(live|test)_[a-zA-Z0-9]{24,}' + 'pk_(live|test)_[a-zA-Z0-9]{24,}' +) + +# Dateien die ignoriert werden sollen +IGNORE_FILES=( + '\.min\.js$' + '\.min\.css$' + 'package-lock\.json$' + 'pnpm-lock\.yaml$' + 'yarn\.lock$' + '\.md$' + '\.txt$' + 'detect-secrets\.sh$' + '\.example$' + '\.sample$' +) + +# Pfade die ignoriert werden sollen +IGNORE_PATHS=( + 'node_modules/' + 'dist/' + '.next/' + 'coverage/' + '.git/' +) + +# Prüfe ob wir in einem Git-Repository sind +if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo -e "${RED}Error: Not a git repository${NC}" + exit 1 +fi + +# Hole staged Dateien +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) + +if [ -z "$STAGED_FILES" ]; then + echo -e "${GREEN}✅ No staged files to check${NC}" + exit 0 +fi + +FOUND_SECRETS=0 + +for file in $STAGED_FILES; do + # Überspringe wenn Datei nicht existiert + [ ! -f "$file" ] && continue + + # Überspringe ignorierte Pfade + skip=false + for ignore_path in "${IGNORE_PATHS[@]}"; do + if [[ "$file" == *"$ignore_path"* ]]; then + skip=true + break + fi + done + [ "$skip" = true ] && continue + + # Überspringe ignorierte Dateitypen + for ignore_file in "${IGNORE_FILES[@]}"; do + if [[ "$file" =~ $ignore_file ]]; then + skip=true + break + fi + done + [ "$skip" = true ] && continue + + # Prüfe jedes Pattern + for pattern in "${PATTERNS[@]}"; do + # Hole nur die staged Version der Datei + matches=$(git show ":$file" 2>/dev/null | grep -niE "$pattern" 2>/dev/null || true) + + if [ -n "$matches" ]; then + echo -e "${RED}⚠️ Potential secret found in: $file${NC}" + echo -e "${YELLOW}Pattern: $pattern${NC}" + echo "$matches" | head -5 + echo "" + FOUND_SECRETS=$((FOUND_SECRETS + 1)) + fi + done +done + +if [ $FOUND_SECRETS -gt 0 ]; then + echo -e "${RED}❌ Found $FOUND_SECRETS potential secret(s)!${NC}" + echo "" + echo "If these are false positives, you can:" + echo " 1. Add the file to IGNORE_FILES in scripts/detect-secrets.sh" + echo " 2. Use 'git commit --no-verify' to skip this check (not recommended)" + echo " 3. Move secrets to environment variables or .env files" + echo "" + exit 1 +fi + +echo -e "${GREEN}✅ No secrets found in staged files${NC}" +exit 0 diff --git a/src/app/(frontend)/api/csrf-token/route.ts b/src/app/(frontend)/api/csrf-token/route.ts new file mode 100644 index 0000000..cdbf3c5 --- /dev/null +++ b/src/app/(frontend)/api/csrf-token/route.ts @@ -0,0 +1,61 @@ +/** + * CSRF Token Endpoint + * + * GET /api/csrf-token + * + * Liefert ein neues CSRF-Token für Browser-basierte API-Aufrufe. + * Das Token wird sowohl im Cookie als auch im Response-Body zurückgegeben. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { generateCsrfToken, CSRF_COOKIE, CSRF_HEADER_NAME } from '@/lib/security/csrf' +import { publicApiLimiter, getClientIp, rateLimitHeaders } from '@/lib/security/rate-limiter' + +export async function GET(req: NextRequest): Promise { + // Rate-Limiting + const ip = getClientIp(req.headers) + const rateLimit = await publicApiLimiter.check(ip) + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: rateLimitHeaders(rateLimit, 60), + }, + ) + } + + // Neues Token generieren + const token = generateCsrfToken() + + // Response mit Token + const response = NextResponse.json({ + success: true, + token, + header: CSRF_HEADER_NAME, + cookie: CSRF_COOKIE, + usage: 'Include the token in both the X-CSRF-Token header and csrf-token cookie for POST/PUT/DELETE requests', + }) + + // Cookie setzen + const tokenExpiry = 60 * 60 // 1 Stunde in Sekunden + response.cookies.set(CSRF_COOKIE, token, { + httpOnly: false, // JavaScript muss lesen können + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + maxAge: tokenExpiry, + }) + + // Token auch im Header + response.headers.set(CSRF_HEADER_NAME, token) + + // Rate-Limit Headers + const rlHeaders = rateLimitHeaders(rateLimit, 60) + for (const [key, value] of Object.entries(rlHeaders)) { + response.headers.set(key, value as string) + } + + return response +} diff --git a/src/app/(payload)/api/send-email/route.ts b/src/app/(payload)/api/send-email/route.ts index cc2313c..44b403d 100644 --- a/src/app/(payload)/api/send-email/route.ts +++ b/src/app/(payload)/api/send-email/route.ts @@ -1,38 +1,17 @@ import { getPayload } from 'payload' import config from '@payload-config' import { sendTenantEmail, sendTestEmail } from '@/lib/email/tenant-email-service' -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { logRateLimit, logAccessDenied } from '@/lib/audit/audit-service' +import { + emailLimiter, + rateLimitHeaders, + validateIpAccess, + createSafeLogger, +} from '@/lib/security' -// Rate Limiting: Max 10 E-Mails pro Minute pro User -const rateLimitMap = new Map() const RATE_LIMIT_MAX = 10 -const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 Minute - -function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } { - const now = Date.now() - const userLimit = rateLimitMap.get(userId) - - if (!userLimit || now > userLimit.resetTime) { - rateLimitMap.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }) - return { allowed: true, remaining: RATE_LIMIT_MAX - 1, resetIn: RATE_LIMIT_WINDOW_MS } - } - - if (userLimit.count >= RATE_LIMIT_MAX) { - return { - allowed: false, - remaining: 0, - resetIn: userLimit.resetTime - now, - } - } - - userLimit.count++ - return { - allowed: true, - remaining: RATE_LIMIT_MAX - userLimit.count, - resetIn: userLimit.resetTime - now, - } -} +const logger = createSafeLogger('API:SendEmail') interface UserWithTenants { id: number @@ -76,9 +55,25 @@ function userHasAccessToTenant(user: UserWithTenants, tenantId: number): boolean * - text?: string * - replyTo?: string * - test?: boolean (sendet Test-E-Mail) + * + * Security: + * - IP-Allowlist (optional via SEND_EMAIL_ALLOWED_IPS env) + * - Rate-Limiting (10 emails per minute per user) + * - Authentication required + * - Tenant access control */ -export async function POST(req: Request) { +export async function POST(req: NextRequest) { try { + // IP-Allowlist prüfen (falls konfiguriert) + const ipCheck = validateIpAccess(req, 'sendEmail') + if (!ipCheck.allowed) { + logger.warn(`IP blocked: ${ipCheck.ip}`, { reason: ipCheck.reason }) + return NextResponse.json( + { error: 'Access denied' }, + { status: 403 }, + ) + } + const payload = await getPayload({ config }) // Authentifizierung prüfen (aus Cookie/Header) @@ -90,8 +85,8 @@ export async function POST(req: Request) { const typedUser = user as UserWithTenants - // Rate Limiting prüfen - const rateLimit = checkRateLimit(String(typedUser.id)) + // Rate Limiting prüfen (jetzt mit zentralem Limiter) + const rateLimit = await emailLimiter.check(String(typedUser.id)) if (!rateLimit.allowed) { // Audit: Rate-Limit-Ereignis loggen await logRateLimit(payload, '/api/send-email', typedUser.id, user.email as string) @@ -99,15 +94,11 @@ export async function POST(req: Request) { return NextResponse.json( { error: 'Rate limit exceeded', - message: `Maximum ${RATE_LIMIT_MAX} emails per minute. Try again in ${Math.ceil(rateLimit.resetIn / 1000)} seconds.`, + message: `Maximum ${RATE_LIMIT_MAX} emails per minute. Try again in ${rateLimit.retryAfter || 60} seconds.`, }, { status: 429, - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)), - }, + headers: rateLimitHeaders(rateLimit, RATE_LIMIT_MAX), }, ) } @@ -142,11 +133,7 @@ export async function POST(req: Request) { } // Rate Limit Headers hinzufügen - const rateLimitHeaders = { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': String(rateLimit.remaining), - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetIn / 1000)), - } + const rlHeaders = rateLimitHeaders(rateLimit, RATE_LIMIT_MAX) // Test-E-Mail senden if (test) { @@ -167,12 +154,12 @@ export async function POST(req: Request) { messageId: result.messageId, logId: result.logId, }, - { headers: rateLimitHeaders }, + { headers: rlHeaders }, ) } else { return NextResponse.json( { success: false, error: result.error, logId: result.logId }, - { status: 500, headers: rateLimitHeaders }, + { status: 500, headers: rlHeaders }, ) } } @@ -210,16 +197,16 @@ export async function POST(req: Request) { messageId: result.messageId, logId: result.logId, }, - { headers: rateLimitHeaders }, + { headers: rlHeaders }, ) } else { return NextResponse.json( { success: false, error: result.error, logId: result.logId }, - { status: 500, headers: rateLimitHeaders }, + { status: 500, headers: rlHeaders }, ) } } catch (error) { - console.error('[API] send-email error:', error) + logger.error('send-email error', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }, diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 8d114c6..c4790f5 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -1,9 +1,9 @@ import Redis from 'ioredis' -const getRedisClient = () => { +function createRedisClient(): Redis { const host = process.env.REDIS_HOST || 'localhost' const port = parseInt(process.env.REDIS_PORT || '6379') - + if (!process.env.REDIS_HOST) { console.warn('[Redis] REDIS_HOST nicht gesetzt, verwende localhost') } @@ -18,14 +18,49 @@ const getRedisClient = () => { // Singleton Pattern let redisClient: Redis | null = null +let redisAvailable: boolean | null = null export const getRedis = (): Redis => { if (!redisClient) { - redisClient = getRedisClient() + redisClient = createRedisClient() } return redisClient } +/** + * Gibt den Redis-Client zurück oder null wenn nicht verfügbar + */ +export const getRedisClient = (): Redis | null => { + if (!process.env.REDIS_HOST) { + return null + } + + try { + return getRedis() + } catch { + return null + } +} + +/** + * Prüft ob Redis verfügbar ist + */ +export const isRedisAvailable = (): boolean => { + if (redisAvailable !== null) { + return redisAvailable + } + + if (!process.env.REDIS_HOST) { + redisAvailable = false + return false + } + + // Lazy check - setze auf true wenn REDIS_HOST gesetzt ist + // Tatsächliche Verbindungsfehler werden beim ersten Zugriff behandelt + redisAvailable = true + return true +} + // Cache Helper Funktionen export const cache = { async get(key: string): Promise { diff --git a/src/lib/security/csrf.ts b/src/lib/security/csrf.ts new file mode 100644 index 0000000..e1ad4fc --- /dev/null +++ b/src/lib/security/csrf.ts @@ -0,0 +1,216 @@ +/** + * CSRF Protection Service + * + * Implementiert CSRF-Schutz für Browser-basierte API-Aufrufe. + * Verwendet Double Submit Cookie Pattern + Origin-Header-Validierung. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { randomBytes, createHmac } from 'crypto' + +// CSRF-Token Secret (sollte in .env sein) +const CSRF_SECRET = process.env.CSRF_SECRET || process.env.PAYLOAD_SECRET || 'default-csrf-secret' +const CSRF_TOKEN_HEADER = 'X-CSRF-Token' +const CSRF_COOKIE_NAME = 'csrf-token' +const TOKEN_EXPIRY_MS = 60 * 60 * 1000 // 1 Stunde + +/** + * Generiert ein neues CSRF-Token + */ +export function generateCsrfToken(): string { + const timestamp = Date.now().toString(36) + const random = randomBytes(16).toString('hex') + const data = `${timestamp}:${random}` + + const hmac = createHmac('sha256', CSRF_SECRET) + hmac.update(data) + const signature = hmac.digest('hex').substring(0, 16) + + return `${data}:${signature}` +} + +/** + * Validiert ein CSRF-Token + */ +export function validateCsrfToken(token: string): { valid: boolean; reason?: string } { + if (!token) { + return { valid: false, reason: 'No token provided' } + } + + const parts = token.split(':') + if (parts.length !== 3) { + return { valid: false, reason: 'Invalid token format' } + } + + const [timestamp, random, signature] = parts + + // Signatur prüfen + const hmac = createHmac('sha256', CSRF_SECRET) + hmac.update(`${timestamp}:${random}`) + const expectedSignature = hmac.digest('hex').substring(0, 16) + + if (signature !== expectedSignature) { + return { valid: false, reason: 'Invalid signature' } + } + + // Ablaufzeit prüfen + const tokenTime = parseInt(timestamp, 36) + if (isNaN(tokenTime)) { + return { valid: false, reason: 'Invalid timestamp' } + } + + if (Date.now() - tokenTime > TOKEN_EXPIRY_MS) { + return { valid: false, reason: 'Token expired' } + } + + return { valid: true } +} + +/** + * Erlaubte Origins für CSRF-Validierung + */ +function getAllowedOrigins(): string[] { + const origins = [ + process.env.PAYLOAD_PUBLIC_SERVER_URL, + process.env.NEXT_PUBLIC_SERVER_URL, + 'http://localhost:3000', + 'http://localhost:3001', + ].filter((origin): origin is string => !!origin) + + // Zusätzliche Origins aus CORS-Konfiguration + const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || [] + + return [...new Set([...origins, ...corsOrigins])] +} + +/** + * Validiert den Origin-Header + */ +export function validateOrigin(origin: string | null): { valid: boolean; reason?: string } { + if (!origin) { + // Requests ohne Origin (z.B. Server-to-Server) können erlaubt werden + return { valid: true } + } + + const allowedOrigins = getAllowedOrigins() + + // Exakte Übereinstimmung + if (allowedOrigins.includes(origin)) { + return { valid: true } + } + + // Subdomain-Matching für Produktions-Domains + const productionDomains = ['pl.c2sgmbh.de', 'porwoll.de', 'complexcaresolutions.de', 'gunshin.de'] + + for (const domain of productionDomains) { + if (origin.endsWith(domain) && origin.startsWith('https://')) { + return { valid: true } + } + } + + return { valid: false, reason: `Origin ${origin} not allowed` } +} + +/** + * Hauptvalidierungsfunktion für CSRF + */ +export function validateCsrf(req: NextRequest): { + valid: boolean + reason?: string +} { + // 1. Safe Methods brauchen keine CSRF-Prüfung + const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method) + if (safeMethod) { + return { valid: true } + } + + // 2. Origin-Header validieren + const origin = req.headers.get('origin') + const originResult = validateOrigin(origin) + if (!originResult.valid) { + return originResult + } + + // 3. Für API-Requests ohne Browser (Content-Type: application/json ohne Origin) + // können wir auf CSRF-Token verzichten wenn Authorization-Header vorhanden + const hasAuth = req.headers.get('authorization') + const isJsonRequest = req.headers.get('content-type')?.includes('application/json') + + if (hasAuth && isJsonRequest && !origin) { + // Server-to-Server Request mit Auth-Token + return { valid: true } + } + + // 4. Browser-Requests: CSRF-Token validieren + const tokenFromHeader = req.headers.get(CSRF_TOKEN_HEADER) + const tokenFromCookie = req.cookies.get(CSRF_COOKIE_NAME)?.value + + // Double Submit Cookie Pattern: Token muss in Header UND Cookie vorhanden sein + if (!tokenFromHeader || !tokenFromCookie) { + return { valid: false, reason: 'CSRF token missing' } + } + + // Tokens müssen übereinstimmen + if (tokenFromHeader !== tokenFromCookie) { + return { valid: false, reason: 'CSRF token mismatch' } + } + + // Token-Inhalt validieren + return validateCsrfToken(tokenFromHeader) +} + +/** + * Setzt CSRF-Cookie in der Response + */ +export function setCsrfCookie(response: NextResponse): NextResponse { + const token = generateCsrfToken() + + response.cookies.set(CSRF_COOKIE_NAME, token, { + httpOnly: false, // JavaScript muss das Cookie lesen können + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + maxAge: TOKEN_EXPIRY_MS / 1000, + }) + + // Token auch im Header für SPA-Initialisierung + response.headers.set(CSRF_TOKEN_HEADER, token) + + return response +} + +/** + * API-Endpoint zum Abrufen eines neuen CSRF-Tokens + * GET /api/csrf-token + */ +export function handleCsrfTokenRequest(): NextResponse { + const response = NextResponse.json({ success: true }) + return setCsrfCookie(response) +} + +/** + * Middleware-Helper für CSRF-Schutz + */ +export function csrfProtection( + req: NextRequest, + options: { skipPaths?: string[] } = {}, +): { valid: boolean; reason?: string } { + // Bestimmte Pfade überspringen (z.B. Webhooks) + if (options.skipPaths) { + const url = new URL(req.url) + for (const path of options.skipPaths) { + if (url.pathname.startsWith(path)) { + return { valid: true } + } + } + } + + return validateCsrf(req) +} + +// ============================================================================ +// Exports für Client-Side-Nutzung +// ============================================================================ + +export const CSRF_HEADER_NAME = CSRF_TOKEN_HEADER +export const CSRF_COOKIE = CSRF_COOKIE_NAME diff --git a/src/lib/security/data-masking.ts b/src/lib/security/data-masking.ts new file mode 100644 index 0000000..8973155 --- /dev/null +++ b/src/lib/security/data-masking.ts @@ -0,0 +1,265 @@ +/** + * Data Masking Service + * + * Maskiert sensible Daten für Logs, Audit-Einträge und API-Responses. + * Verhindert das Leaken von Passwörtern, Tokens und anderen Secrets. + */ + +// Liste von Feldnamen die immer maskiert werden sollen +const SENSITIVE_FIELD_NAMES = [ + 'password', + 'pass', + 'passwd', + 'secret', + 'token', + 'apikey', + 'api_key', + 'apiKey', + 'auth', + 'authorization', + 'credential', + 'credentials', + 'private', + 'privateKey', + 'private_key', + 'accessToken', + 'access_token', + 'refreshToken', + 'refresh_token', + 'sessionId', + 'session_id', + 'cookie', + 'jwt', + 'bearer', + 'smtp_pass', + 'smtpPass', + 'smtp_password', + 'smtpPassword', + 'db_password', + 'database_password', + 'encryption_key', + 'encryptionKey', + 'salt', + 'hash', + 'resetPasswordToken', + 'verificationToken', +] + +// Patterns für sensible Werte (in Strings) +const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + // Passwörter in verschiedenen Formaten + { pattern: /password['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'password: [REDACTED]' }, + { pattern: /pass['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'pass: [REDACTED]' }, + + // API Keys und Tokens + { pattern: /api[_-]?key['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'apiKey: [REDACTED]' }, + { pattern: /token['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'token: [REDACTED]' }, + { pattern: /bearer\s+[A-Za-z0-9_.-]+/gi, replacement: 'Bearer [REDACTED]' }, + + // Authorization Headers + { pattern: /authorization['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'authorization: [REDACTED]' }, + + // SMTP Credentials + { pattern: /smtp[_-]?pass(?:word)?['":\s]*['"]?[^'"\s,}]+['"]?/gi, replacement: 'smtp_pass: [REDACTED]' }, + + // Connection Strings mit Passwörtern + { pattern: /:\/\/[^:]+:([^@]+)@/g, replacement: '://[USER]:[REDACTED]@' }, + + // Private Keys + { pattern: /-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, replacement: '[PRIVATE KEY REDACTED]' }, + + // JWT Tokens (behalte Header für Debugging, redact Payload und Signature) + { pattern: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, replacement: (match) => { + const parts = match.split('.') + return `${parts[0]}.[PAYLOAD REDACTED].[SIGNATURE REDACTED]` + }}, + + // E-Mail-Adressen teilweise maskieren (für Datenschutz, nicht Security) + // Deaktiviert - E-Mails werden für Audit-Logs benötigt +] + +/** + * Maskiert einen einzelnen String-Wert + */ +export function maskString(value: string): string { + if (!value || typeof value !== 'string') return value + + let masked = value + + for (const { pattern, replacement } of SENSITIVE_PATTERNS) { + if (typeof replacement === 'function') { + masked = masked.replace(pattern, replacement as (substring: string, ...args: unknown[]) => string) + } else { + masked = masked.replace(pattern, replacement) + } + } + + return masked +} + +/** + * Prüft ob ein Feldname sensitiv ist + */ +export function isSensitiveField(fieldName: string): boolean { + const lowerName = fieldName.toLowerCase() + return SENSITIVE_FIELD_NAMES.some((sensitive) => lowerName.includes(sensitive.toLowerCase())) +} + +/** + * Maskiert ein Objekt rekursiv + */ +export function maskObject>( + obj: T, + options: { + /** Maximale Rekursionstiefe */ + maxDepth?: number + /** Zusätzliche Felder zum Maskieren */ + additionalFields?: string[] + /** Felder die NICHT maskiert werden sollen */ + preserveFields?: string[] + } = {}, +): T { + const { maxDepth = 10, additionalFields = [], preserveFields = [] } = options + + function maskRecursive(value: unknown, depth: number, path: string): unknown { + if (depth > maxDepth) return '[MAX DEPTH EXCEEDED]' + + // Null/undefined passieren lassen + if (value === null || value === undefined) return value + + // String maskieren + if (typeof value === 'string') { + return maskString(value) + } + + // Arrays durchlaufen + if (Array.isArray(value)) { + return value.map((item, index) => maskRecursive(item, depth + 1, `${path}[${index}]`)) + } + + // Objekte durchlaufen + if (typeof value === 'object') { + const result: Record = {} + + for (const [key, val] of Object.entries(value)) { + const currentPath = path ? `${path}.${key}` : key + + // Felder die explizit erhalten werden sollen + if (preserveFields.includes(key)) { + result[key] = val + continue + } + + // Sensitive Felder komplett maskieren + if (isSensitiveField(key) || additionalFields.includes(key)) { + if (val === null || val === undefined) { + result[key] = val + } else if (typeof val === 'string' && val.length > 0) { + result[key] = '[REDACTED]' + } else if (typeof val === 'object') { + result[key] = '[REDACTED OBJECT]' + } else { + result[key] = '[REDACTED]' + } + continue + } + + // Rekursiv maskieren + result[key] = maskRecursive(val, depth + 1, currentPath) + } + + return result + } + + // Primitive Werte durchlassen + return value + } + + return maskRecursive(obj, 0, '') as T +} + +/** + * Maskiert Tenant-Daten (speziell für Audit-Logs) + */ +export function maskTenantData(tenant: Record): Record { + return maskObject(tenant, { + additionalFields: ['smtpPass', 'smtp_pass', 'pass'], + preserveFields: ['id', 'name', 'slug', 'domain', 'createdAt', 'updatedAt'], + }) +} + +/** + * Maskiert E-Mail-Log-Daten + */ +export function maskEmailLogData(emailLog: Record): Record { + const masked = { ...emailLog } + + // Error-Messages maskieren + if (masked.error && typeof masked.error === 'string') { + masked.error = maskString(masked.error) + } + + // SMTP-Config maskieren falls vorhanden + if (masked.smtpConfig && typeof masked.smtpConfig === 'object') { + masked.smtpConfig = maskObject(masked.smtpConfig as Record) + } + + return masked +} + +/** + * Maskiert Error-Objekte für sichere Logs + */ +export function maskError(error: Error | unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: maskString(error.message), + stack: error.stack ? maskString(error.stack.substring(0, 1000)) : undefined, + } + } + + if (typeof error === 'string') { + return { message: maskString(error) } + } + + if (typeof error === 'object' && error !== null) { + return maskObject(error as Record) + } + + return { message: String(error) } +} + +/** + * Safe JSON.stringify mit Maskierung + */ +export function safeStringify(obj: unknown, space?: number): string { + try { + if (typeof obj === 'object' && obj !== null) { + return JSON.stringify(maskObject(obj as Record), null, space) + } + if (typeof obj === 'string') { + return maskString(obj) + } + return JSON.stringify(obj, null, space) + } catch { + return '[STRINGIFY ERROR]' + } +} + +/** + * Erstellt eine sichere Log-Funktion + */ +export function createSafeLogger(prefix: string) { + return { + info: (message: string, data?: unknown) => { + console.log(`[${prefix}]`, message, data ? safeStringify(data) : '') + }, + warn: (message: string, data?: unknown) => { + console.warn(`[${prefix}]`, message, data ? safeStringify(data) : '') + }, + error: (message: string, error?: unknown) => { + console.error(`[${prefix}]`, message, error ? safeStringify(maskError(error)) : '') + }, + } +} diff --git a/src/lib/security/index.ts b/src/lib/security/index.ts new file mode 100644 index 0000000..4dc5d46 --- /dev/null +++ b/src/lib/security/index.ts @@ -0,0 +1,54 @@ +/** + * Security Module + * + * Zentraler Export für alle Security-bezogenen Funktionen. + */ + +// Rate Limiting +export { + createRateLimiter, + publicApiLimiter, + authLimiter, + emailLimiter, + searchLimiter, + formLimiter, + strictLimiter, + getClientIp, + rateLimitHeaders, + type RateLimitConfig, + type RateLimitResult, +} from './rate-limiter' + +// IP Allowlist +export { + isIpAllowed, + isIpBlocked, + checkIpAllowlist, + validateIpAccess, + getClientIpFromRequest, +} from './ip-allowlist' + +// CSRF Protection +export { + generateCsrfToken, + validateCsrfToken, + validateOrigin, + validateCsrf, + setCsrfCookie, + handleCsrfTokenRequest, + csrfProtection, + CSRF_HEADER_NAME, + CSRF_COOKIE, +} from './csrf' + +// Data Masking +export { + maskString, + maskObject, + maskTenantData, + maskEmailLogData, + maskError, + safeStringify, + createSafeLogger, + isSensitiveField, +} from './data-masking' diff --git a/src/lib/security/ip-allowlist.ts b/src/lib/security/ip-allowlist.ts new file mode 100644 index 0000000..1193ac8 --- /dev/null +++ b/src/lib/security/ip-allowlist.ts @@ -0,0 +1,204 @@ +/** + * IP Allowlist Service + * + * Ermöglicht die Einschränkung von API-Endpoints auf bestimmte IP-Adressen. + * Konfiguration über Environment-Variablen. + */ + +import { NextRequest } from 'next/server' + +/** + * Prüft ob eine IP-Adresse in einem CIDR-Block liegt + */ +function ipInCidr(ip: string, cidr: string): boolean { + // Einfache Implementierung für IPv4 + const [range, bits] = cidr.split('/') + if (!range || !bits) return ip === cidr + + const mask = parseInt(bits, 10) + if (isNaN(mask) || mask < 0 || mask > 32) return false + + const ipParts = ip.split('.').map(Number) + const rangeParts = range.split('.').map(Number) + + if (ipParts.length !== 4 || rangeParts.length !== 4) return false + if (ipParts.some(isNaN) || rangeParts.some(isNaN)) return false + + const ipNum = + (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3] + const rangeNum = + (rangeParts[0] << 24) | (rangeParts[1] << 16) | (rangeParts[2] << 8) | rangeParts[3] + + const maskNum = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0 + + return (ipNum & maskNum) === (rangeNum & maskNum) +} + +/** + * Prüft ob eine IP in einer Liste von IPs/CIDRs enthalten ist + */ +function ipInList(ip: string, list: string[]): boolean { + for (const entry of list) { + // Exakte Übereinstimmung + if (ip === entry) return true + + // CIDR-Prüfung + if (entry.includes('/') && ipInCidr(ip, entry)) return true + + // Wildcard-Unterstützung (z.B. 192.168.*) + if (entry.includes('*')) { + const pattern = entry.replace(/\./g, '\\.').replace(/\*/g, '.*') + if (new RegExp(`^${pattern}$`).test(ip)) return true + } + } + + return false +} + +/** + * Parsed eine komma-getrennte Liste von IPs/CIDRs aus einem String + */ +function parseIpList(value: string | undefined): string[] { + if (!value) return [] + return value + .split(',') + .map((ip) => ip.trim()) + .filter((ip) => ip.length > 0) +} + +/** + * Extrahiert die Client-IP aus einem NextRequest + */ +export function getClientIpFromRequest(req: NextRequest): string { + const forwarded = req.headers.get('x-forwarded-for') + if (forwarded) { + return forwarded.split(',')[0]?.trim() || 'unknown' + } + + return req.headers.get('x-real-ip') || 'unknown' +} + +// ============================================================================ +// Allowlist-Konfigurationen +// ============================================================================ + +interface AllowlistConfig { + /** Environment-Variable für die Allowlist */ + envVar: string + /** Beschreibung für Logging */ + description: string + /** Fallback wenn keine Konfiguration vorhanden */ + allowAllIfEmpty: boolean +} + +const allowlistConfigs: Record = { + sendEmail: { + envVar: 'SEND_EMAIL_ALLOWED_IPS', + description: '/api/send-email', + allowAllIfEmpty: true, // Erlaube alle wenn nicht konfiguriert + }, + admin: { + envVar: 'ADMIN_ALLOWED_IPS', + description: 'Admin Panel', + allowAllIfEmpty: true, + }, + webhooks: { + envVar: 'WEBHOOK_ALLOWED_IPS', + description: 'Webhook Endpoints', + allowAllIfEmpty: false, // Blockiere alle wenn nicht konfiguriert + }, +} + +/** + * Prüft ob eine IP für einen bestimmten Endpoint erlaubt ist + */ +export function isIpAllowed( + ip: string, + endpoint: keyof typeof allowlistConfigs, +): { allowed: boolean; reason?: string } { + const config = allowlistConfigs[endpoint] + if (!config) { + return { allowed: true } // Unbekannter Endpoint = erlaubt + } + + const allowedIps = parseIpList(process.env[config.envVar]) + + // Keine Konfiguration vorhanden + if (allowedIps.length === 0) { + if (config.allowAllIfEmpty) { + return { allowed: true } + } + return { + allowed: false, + reason: `No IPs configured for ${config.description}`, + } + } + + // Prüfe ob IP in Allowlist + if (ipInList(ip, allowedIps)) { + return { allowed: true } + } + + return { + allowed: false, + reason: `IP ${ip} not in allowlist for ${config.description}`, + } +} + +/** + * Middleware-Helper für IP-Allowlist-Prüfung + */ +export function checkIpAllowlist( + req: NextRequest, + endpoint: keyof typeof allowlistConfigs, +): { allowed: boolean; ip: string; reason?: string } { + const ip = getClientIpFromRequest(req) + const result = isIpAllowed(ip, endpoint) + + return { + ...result, + ip, + } +} + +// ============================================================================ +// Blocklist (für bekannte böswillige IPs) +// ============================================================================ + +/** + * Prüft ob eine IP auf der globalen Blocklist steht + * Konfiguration über BLOCKED_IPS Environment-Variable + */ +export function isIpBlocked(ip: string): boolean { + const blockedIps = parseIpList(process.env.BLOCKED_IPS) + if (blockedIps.length === 0) return false + + return ipInList(ip, blockedIps) +} + +/** + * Kombinierte Prüfung: Nicht blockiert UND erlaubt + */ +export function validateIpAccess( + req: NextRequest, + endpoint: keyof typeof allowlistConfigs, +): { allowed: boolean; ip: string; reason?: string } { + const ip = getClientIpFromRequest(req) + + // Erst Blocklist prüfen + if (isIpBlocked(ip)) { + return { + allowed: false, + ip, + reason: `IP ${ip} is blocked`, + } + } + + // Dann Allowlist prüfen + const allowlistResult = isIpAllowed(ip, endpoint) + + return { + ...allowlistResult, + ip, + } +} diff --git a/src/lib/security/rate-limiter.ts b/src/lib/security/rate-limiter.ts new file mode 100644 index 0000000..4b746cc --- /dev/null +++ b/src/lib/security/rate-limiter.ts @@ -0,0 +1,314 @@ +/** + * Rate Limiter Service + * + * Zentraler Rate-Limiting-Service für alle API-Endpoints. + * Unterstützt verschiedene Limiter-Typen und ist Redis-ready. + */ + +import { getRedisClient, isRedisAvailable } from '../redis' + +export interface RateLimitConfig { + /** Eindeutiger Name für diesen Limiter */ + name: string + /** Maximale Anzahl von Requests */ + maxRequests: number + /** Zeitfenster in Millisekunden */ + windowMs: number + /** Optional: Prefix für Redis-Keys */ + keyPrefix?: string +} + +export interface RateLimitResult { + allowed: boolean + remaining: number + resetIn: number + retryAfter?: number +} + +interface RateLimitEntry { + count: number + windowStart: number +} + +// In-Memory Store als Fallback +const memoryStores = new Map>() + +// Cleanup-Interval für Memory-Stores (alle 5 Minuten) +let cleanupIntervalId: NodeJS.Timeout | null = null + +function startCleanupInterval() { + if (cleanupIntervalId) return + + cleanupIntervalId = setInterval( + () => { + const now = Date.now() + for (const [storeName, store] of memoryStores.entries()) { + for (const [key, entry] of store.entries()) { + // Entferne Einträge die älter als 2 Fenster sind + const config = getConfigByName(storeName) + if (config && now - entry.windowStart > config.windowMs * 2) { + store.delete(key) + } + } + } + }, + 5 * 60 * 1000, + ) +} + +// Config-Registry +const configRegistry = new Map() + +function getConfigByName(name: string): RateLimitConfig | undefined { + return configRegistry.get(name) +} + +/** + * Erstellt einen Rate-Limiter mit der gegebenen Konfiguration + */ +export function createRateLimiter(config: RateLimitConfig) { + configRegistry.set(config.name, config) + + // Memory-Store für diesen Limiter initialisieren + if (!memoryStores.has(config.name)) { + memoryStores.set(config.name, new Map()) + } + + startCleanupInterval() + + return { + /** + * Prüft ob ein Request erlaubt ist + * @param identifier - Eindeutige ID (z.B. IP-Adresse oder User-ID) + */ + check: async (identifier: string): Promise => { + return checkRateLimit(config, identifier) + }, + + /** + * Setzt das Limit für einen Identifier zurück + */ + reset: async (identifier: string): Promise => { + return resetRateLimit(config, identifier) + }, + } +} + +async function checkRateLimit( + config: RateLimitConfig, + identifier: string, +): Promise { + const key = `${config.keyPrefix || 'ratelimit'}:${config.name}:${identifier}` + const now = Date.now() + + // Versuche Redis, falls verfügbar + if (isRedisAvailable()) { + try { + return await checkRateLimitRedis(config, key, now) + } catch (error) { + console.warn('[RateLimiter] Redis error, falling back to memory:', error) + } + } + + // Fallback: In-Memory + return checkRateLimitMemory(config, identifier, now) +} + +async function checkRateLimitRedis( + config: RateLimitConfig, + key: string, + now: number, +): Promise { + const redis = getRedisClient() + if (!redis) throw new Error('Redis not available') + + const windowStart = now - (now % config.windowMs) + const windowKey = `${key}:${windowStart}` + + // Atomic increment mit TTL + const count = await redis.incr(windowKey) + + if (count === 1) { + // Erster Request in diesem Fenster - TTL setzen + await redis.pexpire(windowKey, config.windowMs + 1000) + } + + const remaining = Math.max(0, config.maxRequests - count) + const resetIn = windowStart + config.windowMs - now + + if (count > config.maxRequests) { + return { + allowed: false, + remaining: 0, + resetIn, + retryAfter: Math.ceil(resetIn / 1000), + } + } + + return { + allowed: true, + remaining, + resetIn, + } +} + +function checkRateLimitMemory( + config: RateLimitConfig, + identifier: string, + now: number, +): RateLimitResult { + const store = memoryStores.get(config.name) + if (!store) { + throw new Error(`Rate limiter store not found: ${config.name}`) + } + + const entry = store.get(identifier) + + // Neues Fenster oder erster Request + if (!entry || now - entry.windowStart > config.windowMs) { + store.set(identifier, { count: 1, windowStart: now }) + return { + allowed: true, + remaining: config.maxRequests - 1, + resetIn: config.windowMs, + } + } + + // Limit erreicht + if (entry.count >= config.maxRequests) { + const resetIn = entry.windowStart + config.windowMs - now + return { + allowed: false, + remaining: 0, + resetIn, + retryAfter: Math.ceil(resetIn / 1000), + } + } + + // Request erlaubt + entry.count++ + const resetIn = entry.windowStart + config.windowMs - now + + return { + allowed: true, + remaining: config.maxRequests - entry.count, + resetIn, + } +} + +async function resetRateLimit(config: RateLimitConfig, identifier: string): Promise { + const key = `${config.keyPrefix || 'ratelimit'}:${config.name}:${identifier}` + + if (isRedisAvailable()) { + try { + const redis = getRedisClient() + if (redis) { + // Lösche alle Keys für diesen Identifier (pattern match) + const keys = await redis.keys(`${key}:*`) + if (keys.length > 0) { + await redis.del(...keys) + } + } + } catch (error) { + console.warn('[RateLimiter] Redis reset error:', error) + } + } + + // Memory-Store bereinigen + const store = memoryStores.get(config.name) + if (store) { + store.delete(identifier) + } +} + +// ============================================================================ +// Vordefinierte Rate-Limiters für verschiedene Endpoints +// ============================================================================ + +/** + * Rate-Limiter für öffentliche API-Endpoints + * 60 Requests pro Minute pro IP + */ +export const publicApiLimiter = createRateLimiter({ + name: 'public-api', + maxRequests: 60, + windowMs: 60 * 1000, +}) + +/** + * Rate-Limiter für Authentifizierungs-Endpoints + * 5 Versuche pro 15 Minuten pro IP (gegen Brute-Force) + */ +export const authLimiter = createRateLimiter({ + name: 'auth', + maxRequests: 5, + windowMs: 15 * 60 * 1000, +}) + +/** + * Rate-Limiter für E-Mail-Versand + * 10 E-Mails pro Minute pro User + */ +export const emailLimiter = createRateLimiter({ + name: 'email', + maxRequests: 10, + windowMs: 60 * 1000, +}) + +/** + * Rate-Limiter für Such-Endpoints + * 30 Requests pro Minute pro IP + */ +export const searchLimiter = createRateLimiter({ + name: 'search', + maxRequests: 30, + windowMs: 60 * 1000, +}) + +/** + * Rate-Limiter für Form-Submissions + * 5 Submissions pro 10 Minuten pro IP (gegen Spam) + */ +export const formLimiter = createRateLimiter({ + name: 'form', + maxRequests: 5, + windowMs: 10 * 60 * 1000, +}) + +/** + * Strenger Rate-Limiter für sensible Endpoints + * 3 Requests pro Stunde pro IP + */ +export const strictLimiter = createRateLimiter({ + name: 'strict', + maxRequests: 3, + windowMs: 60 * 60 * 1000, +}) + +// ============================================================================ +// Hilfsfunktionen +// ============================================================================ + +/** + * Extrahiert die Client-IP aus Request-Headers + */ +export function getClientIp(headers: Headers): string { + const forwarded = headers.get('x-forwarded-for') + if (forwarded) { + return forwarded.split(',')[0]?.trim() || 'unknown' + } + + return headers.get('x-real-ip') || 'unknown' +} + +/** + * Erstellt Standard-Rate-Limit-Headers für Response + */ +export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit { + return { + 'X-RateLimit-Limit': String(maxRequests), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(Math.ceil(result.resetIn / 1000)), + ...(result.retryAfter ? { 'Retry-After': String(result.retryAfter) } : {}), + } +}