mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue