mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
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:
parent
fc94531931
commit
cb2e903db5
6 changed files with 157 additions and 42 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue