fix: integrate security modules into actual endpoints

Rate Limiting Integration:
- Add authLimiter (5 attempts/15min) to both login routes for brute-force protection
- Migrate search endpoints from local checkRateLimit to central searchLimiter
- Add IP blocklist checks to auth and search endpoints

Data Masking Integration:
- Integrate maskObject/maskString from security module into audit-service
- Auto-mask previousValue, newValue, metadata, and descriptions in audit logs
- Use maskError for error logging

Pre-commit Hook:
- Add "prepare" script to package.json for automatic hook installation
- Hook is now installed automatically on pnpm install

Note: CSRF middleware is available but not enforced on API routes since
Payload CMS uses JWT auth and has built-in CORS/CSRF protection in config.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-07 23:34:08 +00:00
parent fc94531931
commit cb2e903db5
6 changed files with 157 additions and 42 deletions

View file

@ -15,7 +15,8 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e", "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: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": { "dependencies": {
"@payloadcms/db-postgres": "3.65.0", "@payloadcms/db-postgres": "3.65.0",

View file

@ -4,33 +4,42 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' 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 // Validation constants
const MIN_QUERY_LENGTH = 2 const MIN_QUERY_LENGTH = 2
const MAX_QUERY_LENGTH = 100 const MAX_QUERY_LENGTH = 100
const MAX_LIMIT = 50 const MAX_LIMIT = 50
const DEFAULT_LIMIT = 10 const DEFAULT_LIMIT = 10
const SEARCH_RATE_LIMIT = 30
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Rate limiting // IP-Blocklist prüfen
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || const ip = getClientIpFromRequest(request)
request.headers.get('x-real-ip') || if (isIpBlocked(ip)) {
'unknown' return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 },
)
}
const rateLimit = checkRateLimit(ip) // Rate limiting (zentral)
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
return NextResponse.json( return NextResponse.json(
{ error: 'Too many requests. Please try again later.' }, { error: 'Too many requests. Please try again later.' },
{ {
status: 429, status: 429,
headers: { headers: rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT),
'Retry-After': String(rateLimit.retryAfter || 60),
'X-RateLimit-Remaining': '0',
}, },
}
) )
} }
@ -104,7 +113,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(result, { return NextResponse.json(result, {
headers: { headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining), ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=60', 'Cache-Control': 'public, max-age=60, s-maxage=60',
}, },
}) })

View file

@ -4,33 +4,42 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' 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 // Validation constants
const MIN_QUERY_LENGTH = 2 const MIN_QUERY_LENGTH = 2
const MAX_QUERY_LENGTH = 50 const MAX_QUERY_LENGTH = 50
const MAX_LIMIT = 10 const MAX_LIMIT = 10
const DEFAULT_LIMIT = 5 const DEFAULT_LIMIT = 5
const SEARCH_RATE_LIMIT = 30
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Rate limiting // IP-Blocklist prüfen
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || const ip = getClientIpFromRequest(request)
request.headers.get('x-real-ip') || if (isIpBlocked(ip)) {
'unknown' return NextResponse.json(
{ suggestions: [] },
{ status: 403 },
)
}
const rateLimit = checkRateLimit(ip) // Rate limiting (zentral)
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
return NextResponse.json( return NextResponse.json(
{ suggestions: [] }, { suggestions: [] },
{ {
status: 429, status: 429,
headers: { headers: rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT),
'Retry-After': String(rateLimit.retryAfter || 60),
'X-RateLimit-Remaining': '0',
}, },
}
) )
} }
@ -52,10 +61,10 @@ export async function GET(request: NextRequest) {
{ suggestions: [] }, { suggestions: [] },
{ {
headers: { headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining), ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=60', 'Cache-Control': 'public, max-age=60, s-maxage=60',
}, },
} },
) )
} }
@ -97,10 +106,10 @@ export async function GET(request: NextRequest) {
{ suggestions }, { suggestions },
{ {
headers: { headers: {
'X-RateLimit-Remaining': String(rateLimit.remaining), ...rateLimitHeaders(rateLimit, SEARCH_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=60', 'Cache-Control': 'public, max-age=60, s-maxage=60',
}, },
} },
) )
} catch (error) { } catch (error) {
console.error('[Suggestions API] Error:', error) console.error('[Suggestions API] Error:', error)

View file

@ -7,6 +7,11 @@
* Login-Versuche im Audit-Log. Erfolgreiche Logins werden durch den * Login-Versuche im Audit-Log. Erfolgreiche Logins werden durch den
* afterLogin-Hook in der Users-Collection geloggt. * 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: * Body:
* - email: string (erforderlich) * - email: string (erforderlich)
* - password: string (erforderlich) * - password: string (erforderlich)
@ -15,7 +20,13 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server' 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 * 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<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
try { 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 payload = await getPayload({ config: configPromise })
const body = await req.json() const body = await req.json()

View file

@ -7,13 +7,24 @@
* Login-Versuche im Audit-Log zu erfassen. Dies ist notwendig, weil Payload * Login-Versuche im Audit-Log zu erfassen. Dies ist notwendig, weil Payload
* keinen nativen afterLoginFailed Hook hat. * 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. * Erfolgreiche Logins werden weiterhin durch den afterLogin-Hook geloggt.
*/ */
import { getPayload } from 'payload' import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { NextRequest, NextResponse } from 'next/server' 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 * 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<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
// 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 }) const payload = await getPayload({ config: configPromise })
try { try {

View file

@ -6,6 +6,7 @@
*/ */
import type { Payload, PayloadRequest } from 'payload' import type { Payload, PayloadRequest } from 'payload'
import { maskString, maskObject, maskError } from '../security/data-masking'
export type AuditAction = export type AuditAction =
| 'login_success' | 'login_success'
@ -133,6 +134,8 @@ function getDefaultSeverity(action: AuditAction): AuditSeverity {
/** /**
* Erstellt einen Audit-Log-Eintrag * Erstellt einen Audit-Log-Eintrag
*
* Sensible Daten in previousValue, newValue und metadata werden automatisch maskiert.
*/ */
export async function createAuditLog( export async function createAuditLog(
payload: Payload, payload: Payload,
@ -142,6 +145,13 @@ export async function createAuditLog(
try { try {
const clientInfo = getClientInfo(req) 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 // Type assertion notwendig bis payload-types.ts regeneriert wird
await (payload.create as Function)({ await (payload.create as Function)({
collection: 'audit-logs', collection: 'audit-logs',
@ -155,17 +165,18 @@ export async function createAuditLog(
tenant: input.tenantId, tenant: input.tenantId,
ipAddress: input.ipAddress || clientInfo.ipAddress, ipAddress: input.ipAddress || clientInfo.ipAddress,
userAgent: input.userAgent || clientInfo.userAgent, userAgent: input.userAgent || clientInfo.userAgent,
description: input.description, description: input.description ? maskString(input.description) : undefined,
previousValue: input.previousValue, previousValue: maskedPreviousValue,
newValue: input.newValue, newValue: maskedNewValue,
metadata: input.metadata, metadata: maskedMetadata,
}, },
// Bypass Access Control für System-Logging // Bypass Access Control für System-Logging
overrideAccess: true, overrideAccess: true,
}) })
} catch (error) { } catch (error) {
// Fehler beim Audit-Logging sollten die Hauptoperation nicht blockieren // 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 * Maskiert sensible Daten in Fehlermeldungen
* Verwendet jetzt den zentralen Data-Masking-Service
*/ */
function maskSensitiveData(text: string): string { function maskSensitiveData(text: string): string {
// Maskiere Passwörter, Tokens, etc. return maskString(text)
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]')
} }
/**
* Maskiert Objekte für Audit-Logs (previousValue, newValue, metadata)
*/
function maskAuditData(data: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!data) return undefined
return maskObject(data)
}
// Re-export für externe Nutzung
export { maskError, maskObject, maskString }