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",
"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",

View file

@ -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',
},
})

View file

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

View file

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

View file

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

View file

@ -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<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!data) return undefined
return maskObject(data)
}
// Re-export für externe Nutzung
export { maskError, maskObject, maskString }