diff --git a/package.json b/package.json index a79937d..7d2bd16 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "start": "cross-env NODE_OPTIONS=--no-deprecation next start", "test": "pnpm run test:int && pnpm run test:e2e", "test:e2e": "test -f .next/BUILD_ID || (echo 'Error: No build found. Run pnpm build first.' && exit 1) && cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test", - "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" + "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts", + "prepare": "test -d .git && (ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit 2>/dev/null || true) || true" }, "dependencies": { "@payloadcms/db-postgres": "3.65.0", diff --git a/src/app/(frontend)/api/search/route.ts b/src/app/(frontend)/api/search/route.ts index 43270ac..cc0dd5a 100644 --- a/src/app/(frontend)/api/search/route.ts +++ b/src/app/(frontend)/api/search/route.ts @@ -4,33 +4,42 @@ import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' -import { searchPosts, checkRateLimit } from '@/lib/search' +import { searchPosts } from '@/lib/search' +import { + searchLimiter, + rateLimitHeaders, + getClientIpFromRequest, + isIpBlocked, +} from '@/lib/security' // Validation constants const MIN_QUERY_LENGTH = 2 const MAX_QUERY_LENGTH = 100 const MAX_LIMIT = 50 const DEFAULT_LIMIT = 10 +const SEARCH_RATE_LIMIT = 30 export async function GET(request: NextRequest) { try { - // Rate limiting - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' + // IP-Blocklist prüfen + const ip = getClientIpFromRequest(request) + if (isIpBlocked(ip)) { + return NextResponse.json( + { error: 'Access denied' }, + { status: 403 }, + ) + } - const rateLimit = checkRateLimit(ip) + // Rate limiting (zentral) + const rateLimit = await searchLimiter.check(ip) if (!rateLimit.allowed) { return NextResponse.json( { error: 'Too many requests. Please try again later.' }, { status: 429, - headers: { - 'Retry-After': String(rateLimit.retryAfter || 60), - 'X-RateLimit-Remaining': '0', - }, - } + headers: rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT), + }, ) } @@ -104,7 +113,7 @@ export async function GET(request: NextRequest) { return NextResponse.json(result, { headers: { - 'X-RateLimit-Remaining': String(rateLimit.remaining), + ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT), 'Cache-Control': 'public, max-age=60, s-maxage=60', }, }) diff --git a/src/app/(frontend)/api/search/suggestions/route.ts b/src/app/(frontend)/api/search/suggestions/route.ts index 55033aa..06500cd 100644 --- a/src/app/(frontend)/api/search/suggestions/route.ts +++ b/src/app/(frontend)/api/search/suggestions/route.ts @@ -4,33 +4,42 @@ import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' -import { getSearchSuggestions, checkRateLimit } from '@/lib/search' +import { getSearchSuggestions } from '@/lib/search' +import { + searchLimiter, + rateLimitHeaders, + getClientIpFromRequest, + isIpBlocked, +} from '@/lib/security' // Validation constants const MIN_QUERY_LENGTH = 2 const MAX_QUERY_LENGTH = 50 const MAX_LIMIT = 10 const DEFAULT_LIMIT = 5 +const SEARCH_RATE_LIMIT = 30 export async function GET(request: NextRequest) { try { - // Rate limiting - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' + // IP-Blocklist prüfen + const ip = getClientIpFromRequest(request) + if (isIpBlocked(ip)) { + return NextResponse.json( + { suggestions: [] }, + { status: 403 }, + ) + } - const rateLimit = checkRateLimit(ip) + // Rate limiting (zentral) + const rateLimit = await searchLimiter.check(ip) if (!rateLimit.allowed) { return NextResponse.json( { suggestions: [] }, { status: 429, - headers: { - 'Retry-After': String(rateLimit.retryAfter || 60), - 'X-RateLimit-Remaining': '0', - }, - } + headers: rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT), + }, ) } @@ -52,10 +61,10 @@ export async function GET(request: NextRequest) { { suggestions: [] }, { headers: { - 'X-RateLimit-Remaining': String(rateLimit.remaining), + ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT), 'Cache-Control': 'public, max-age=60, s-maxage=60', }, - } + }, ) } @@ -97,10 +106,10 @@ export async function GET(request: NextRequest) { { suggestions }, { headers: { - 'X-RateLimit-Remaining': String(rateLimit.remaining), + ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT), 'Cache-Control': 'public, max-age=60, s-maxage=60', }, - } + }, ) } catch (error) { console.error('[Suggestions API] Error:', error) diff --git a/src/app/(payload)/api/auth/login/route.ts b/src/app/(payload)/api/auth/login/route.ts index 12c0d0b..ae52f53 100644 --- a/src/app/(payload)/api/auth/login/route.ts +++ b/src/app/(payload)/api/auth/login/route.ts @@ -7,6 +7,11 @@ * Login-Versuche im Audit-Log. Erfolgreiche Logins werden durch den * afterLogin-Hook in der Users-Collection geloggt. * + * Security: + * - Rate-Limiting: 5 Versuche pro 15 Minuten pro IP (Anti-Brute-Force) + * - IP-Blocklist-Prüfung + * - Audit-Logging für fehlgeschlagene Logins + * * Body: * - email: string (erforderlich) * - password: string (erforderlich) @@ -15,7 +20,13 @@ import { getPayload } from 'payload' import configPromise from '@payload-config' import { NextRequest, NextResponse } from 'next/server' -import { logLoginFailed } from '@/lib/audit/audit-service' +import { logLoginFailed, logRateLimit } from '@/lib/audit/audit-service' +import { + authLimiter, + rateLimitHeaders, + getClientIpFromRequest, + isIpBlocked, +} from '@/lib/security' /** * Extrahiert Client-Informationen aus dem Request für Audit-Logging @@ -33,6 +44,33 @@ function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string export async function POST(req: NextRequest): Promise { try { + // IP-Blocklist prüfen + const clientIp = getClientIpFromRequest(req) + if (isIpBlocked(clientIp)) { + return NextResponse.json( + { success: false, error: 'Access denied' }, + { status: 403 }, + ) + } + + // Rate-Limiting prüfen (Anti-Brute-Force) + const rateLimit = await authLimiter.check(clientIp) + if (!rateLimit.allowed) { + const payload = await getPayload({ config: configPromise }) + await logRateLimit(payload, '/api/auth/login', undefined, undefined) + + return NextResponse.json( + { + success: false, + error: 'Too many login attempts. Please try again later.', + }, + { + status: 429, + headers: rateLimitHeaders(rateLimit, 5), + }, + ) + } + const payload = await getPayload({ config: configPromise }) const body = await req.json() diff --git a/src/app/(payload)/api/users/login/route.ts b/src/app/(payload)/api/users/login/route.ts index 7486c29..f77189d 100644 --- a/src/app/(payload)/api/users/login/route.ts +++ b/src/app/(payload)/api/users/login/route.ts @@ -7,13 +7,24 @@ * Login-Versuche im Audit-Log zu erfassen. Dies ist notwendig, weil Payload * keinen nativen afterLoginFailed Hook hat. * + * Security: + * - Rate-Limiting: 5 Versuche pro 15 Minuten pro IP (Anti-Brute-Force) + * - IP-Blocklist-Prüfung + * - Audit-Logging für fehlgeschlagene Logins + * * Erfolgreiche Logins werden weiterhin durch den afterLogin-Hook geloggt. */ import { getPayload } from 'payload' import configPromise from '@payload-config' import { NextRequest, NextResponse } from 'next/server' -import { logLoginFailed } from '@/lib/audit/audit-service' +import { logLoginFailed, logRateLimit } from '@/lib/audit/audit-service' +import { + authLimiter, + rateLimitHeaders, + getClientIpFromRequest, + isIpBlocked, +} from '@/lib/security' /** * Extrahiert Client-Informationen aus dem Request für Audit-Logging @@ -30,6 +41,36 @@ function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } export async function POST(req: NextRequest): Promise { + // IP-Blocklist prüfen + const clientIp = getClientIpFromRequest(req) + if (isIpBlocked(clientIp)) { + return NextResponse.json( + { errors: [{ message: 'Access denied' }] }, + { status: 403 }, + ) + } + + // Rate-Limiting prüfen (Anti-Brute-Force) + const rateLimit = await authLimiter.check(clientIp) + if (!rateLimit.allowed) { + const payload = await getPayload({ config: configPromise }) + await logRateLimit(payload, '/api/users/login', undefined, undefined) + + return NextResponse.json( + { + errors: [ + { + message: 'Too many login attempts. Please try again later.', + }, + ], + }, + { + status: 429, + headers: rateLimitHeaders(rateLimit, 5), + }, + ) + } + const payload = await getPayload({ config: configPromise }) try { diff --git a/src/lib/audit/audit-service.ts b/src/lib/audit/audit-service.ts index 62b40e8..a6a451e 100644 --- a/src/lib/audit/audit-service.ts +++ b/src/lib/audit/audit-service.ts @@ -6,6 +6,7 @@ */ import type { Payload, PayloadRequest } from 'payload' +import { maskString, maskObject, maskError } from '../security/data-masking' export type AuditAction = | 'login_success' @@ -133,6 +134,8 @@ function getDefaultSeverity(action: AuditAction): AuditSeverity { /** * Erstellt einen Audit-Log-Eintrag + * + * Sensible Daten in previousValue, newValue und metadata werden automatisch maskiert. */ export async function createAuditLog( payload: Payload, @@ -142,6 +145,13 @@ export async function createAuditLog( try { const clientInfo = getClientInfo(req) + // Sensible Daten in Objekten maskieren + const maskedPreviousValue = input.previousValue + ? maskObject(input.previousValue) + : undefined + const maskedNewValue = input.newValue ? maskObject(input.newValue) : undefined + const maskedMetadata = input.metadata ? maskObject(input.metadata) : undefined + // Type assertion notwendig bis payload-types.ts regeneriert wird await (payload.create as Function)({ collection: 'audit-logs', @@ -155,17 +165,18 @@ export async function createAuditLog( tenant: input.tenantId, ipAddress: input.ipAddress || clientInfo.ipAddress, userAgent: input.userAgent || clientInfo.userAgent, - description: input.description, - previousValue: input.previousValue, - newValue: input.newValue, - metadata: input.metadata, + description: input.description ? maskString(input.description) : undefined, + previousValue: maskedPreviousValue, + newValue: maskedNewValue, + metadata: maskedMetadata, }, // Bypass Access Control für System-Logging overrideAccess: true, }) } catch (error) { // Fehler beim Audit-Logging sollten die Hauptoperation nicht blockieren - console.error('[AuditService] Error creating audit log:', error) + // Auch Fehlermeldungen maskieren + console.error('[AuditService] Error creating audit log:', maskError(error)) } } @@ -454,13 +465,19 @@ export async function logRateLimit( /** * Maskiert sensible Daten in Fehlermeldungen + * Verwendet jetzt den zentralen Data-Masking-Service */ function maskSensitiveData(text: string): string { - // Maskiere Passwörter, Tokens, etc. - return text - .replace(/password['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'password: [REDACTED]') - .replace(/pass['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'pass: [REDACTED]') - .replace(/secret['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'secret: [REDACTED]') - .replace(/token['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'token: [REDACTED]') - .replace(/auth['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'auth: [REDACTED]') + return maskString(text) } + +/** + * Maskiert Objekte für Audit-Logs (previousValue, newValue, metadata) + */ +function maskAuditData(data: Record | undefined): Record | undefined { + if (!data) return undefined + return maskObject(data) +} + +// Re-export für externe Nutzung +export { maskError, maskObject, maskString }