15 KiB
Security-Richtlinien - Payload CMS Multi-Tenant
Letzte Aktualisierung: 17.02.2026
Übersicht
Dieses Dokument beschreibt die implementierten Sicherheitsmaßnahmen für das Payload CMS Multi-Tenant-Projekt.
Umgebungen:
| Umgebung | URL | TRUST_PROXY |
|---|---|---|
| Production | https://cms.c2sgmbh.de | true (Nginx) |
| Staging | https://pl.porwoll.tech | true (Caddy) |
Security-Module
Alle Security-Funktionen befinden sich in src/lib/security/:
| Modul | Datei | Zweck |
|---|---|---|
| Rate Limiter | rate-limiter.ts |
Schutz vor API-Missbrauch |
| IP Allowlist | ip-allowlist.ts |
IP-basierte Zugriffskontrolle |
| CSRF Protection | csrf.ts |
Cross-Site Request Forgery Schutz |
| Data Masking | data-masking.ts |
Sensitive Daten in Logs maskieren |
| Cron Auth | cron-auth.ts |
Verbindliche Authentifizierung für Cron-Endpunkte |
| API Guards | api-guards.ts |
Zentrale Guards für Auth/IP/CSRF/Rate-Limit |
| Cron Coordination | cron-coordination.ts |
Multi-Instance-Lock + Idempotency für Cron-Ausführung |
| Security Observability | security-observability.ts |
Strukturierte Security-Events, Metriken, Alert-Schwellen |
| Secrets Health | secrets-health.ts |
Monitoring für fehlende/ablaufende/überfällige Secrets |
Rate Limiter
Vordefinierte Limiter:
| Name | Limit | Fenster | Verwendung |
|---|---|---|---|
publicApiLimiter |
60 Requests | 1 Minute | Öffentliche API-Endpunkte |
authLimiter |
5 Requests | 15 Minuten | Login-Versuche |
emailLimiter |
10 Requests | 1 Minute | E-Mail-Versand |
searchLimiter |
30 Requests | 1 Minute | Suche & Posts-API |
formLimiter |
5 Requests | 10 Minuten | Formular-Submissions |
Verwendung:
import { searchLimiter, rateLimitHeaders } from '@/lib/security'
export async function GET(req: NextRequest) {
const ip = getClientIp(req.headers)
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: rateLimitHeaders(rateLimit, 30)
}
)
}
// ...
}
Redis-Support:
- Automatischer Fallback auf In-Memory-Store wenn Redis nicht verfügbar
- Für verteilte Systeme: Redis via
REDIS_URLkonfigurieren
IP Allowlist/Blocklist
WICHTIG: TRUST_PROXY Konfiguration
Wenn die Anwendung hinter einem Reverse-Proxy (Caddy, Nginx, etc.) läuft, muss
TRUST_PROXY=truegesetzt werden. Ohne diese Einstellung werdenX-Forwarded-ForundX-Real-IPHeader ignoriert, um IP-Spoofing zu verhindern.# In .env setzen wenn hinter Reverse-Proxy: TRUST_PROXY=trueOhne
TRUST_PROXY=truewird für alle Requestsdirect-connectionals IP verwendet, was Rate-Limiting und IP-Allowlists/-Blocklists ineffektiv macht.
Environment-Variablen:
| Variable | Zweck | Format |
|---|---|---|
TRUST_PROXY |
Proxy-Header vertrauen | true oder leer |
BLOCKED_IPS |
Globale Blocklist | IP, CIDR, Wildcard |
SEND_EMAIL_ALLOWED_IPS |
E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard |
GENERATE_PDF_ALLOWED_IPS |
PDF-Endpoint Allowlist | IP, CIDR, Wildcard |
ADMIN_ALLOWED_IPS |
Admin-Panel Allowlist | IP, CIDR, Wildcard |
WEBHOOK_ALLOWED_IPS |
Webhook-Endpoint Allowlist | IP, CIDR, Wildcard |
Unterstützte Formate:
# Einzelne IP
BLOCKED_IPS=192.168.1.100
# Mehrere IPs
BLOCKED_IPS=192.168.1.100,10.0.0.50
# CIDR-Notation
BLOCKED_IPS=10.0.0.0/24
# Wildcard
BLOCKED_IPS=192.168.*
# Kombiniert
BLOCKED_IPS=10.0.0.0/8,192.168.1.50,172.16.*
Verwendung:
import { validateIpAccess } from '@/lib/security'
const ipCheck = validateIpAccess(req, 'sendEmail')
if (!ipCheck.allowed) {
return NextResponse.json(
{ error: 'Access denied', reason: ipCheck.reason },
{ status: 403 }
)
}
CSRF Protection
Pattern: Double Submit Cookie
Token-Endpoint: GET /api/csrf-token
WICHTIG: CSRF_SECRET in Production
In Production muss entweder
CSRF_SECREToderPAYLOAD_SECRETkonfiguriert sein. Ohne Secret startet der Server nicht. Ein vorhersagbares Secret würde Angreifern erlauben, gültige CSRF-Tokens offline zu generieren.# In .env setzen: CSRF_SECRET=mindestens-32-zeichen-langes-geheimnis # ODER PAYLOAD_SECRET wird als Fallback verwendet
Frontend-Integration:
// 1. Token abrufen
const res = await fetch('/api/csrf-token', { credentials: 'include' })
const { csrfToken } = await res.json()
// 2. Bei Mutations mitschicken
await fetch('/api/send-email', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
body: JSON.stringify({ /* ... */ })
})
Server-seitige Validierung:
import { validateCsrf } from '@/lib/security'
const csrf = validateCsrf(req)
if (!csrf.valid) {
return NextResponse.json(
{ error: 'CSRF validation failed' },
{ status: 403 }
)
}
Bypass für Server-to-Server:
- Requests ohne
Origin/RefererHeader werden als Server-to-Server behandelt - API-Key-basierte Authentifizierung umgeht CSRF-Check
Cron Endpoint Auth
Alle /api/cron/* Endpunkte sind fail-closed abgesichert:
- Ohne gültigen
Authorization: Bearer <CRON_SECRET>Header:401 - Ohne gesetztes
CRON_SECRET:503 - Gilt für
GET,POSTundHEAD - Mit
withCronExecution(...):- Verteiltes Execution-Lock (Redis, In-Memory-Fallback)
- Idempotency-Key-Schutz über
x-idempotency-keyoder?idempotencyKey=... - Doppelte Trigger werden mit
202ignoriert
In Production wird CRON_SECRET beim Startup validiert.
PDF URL Hardening (SSRF-Schutz)
PDF-Generierung per URL (/api/generate-pdf) validiert jetzt strikt:
- Nur
https://URLs (Ausnahme:PDF_ALLOW_HTTP_URLS=truenur non-production) - Keine URL-Credentials (
user:pass@host) - Keine localhost/private/loopback Ziele
- DNS-Auflösung wird geprüft (Rebinding-Schutz gegen private Zieladressen)
- Optionaler Host-Allowlist-Modus via
PDF_ALLOWED_HOSTS
Beispiel:
PDF_ALLOWED_HOSTS=example.com,.example.com
PDF_ALLOW_HTTP_URLS=false
Geblockte SSRF-Versuche erzeugen Security-Events (pdf_ssrf_blocked) und können Alert-Schwellen auslösen.
Zentrale API Guards und Request-Validierung
runApiGuards(...) bündelt folgende Checks konsistent:
- IP-Blocklist / IP-Allowlist
- CSRF (browser-basiert, server-to-server aware)
- Authentifizierung (
payload.auth) - Rate-Limiting inkl. standardisierter 429-Antwort
Die Body-Validierung läuft über src/lib/validation/api-validation.ts mit:
- einheitlichen Fehlercodes (
required,invalid_type,invalid_value,invalid_json) - strukturierter Fehlerantwort (
VALIDATION_FAILED) - optionalem Security-Event (
request_validation_failed)
Security Observability (Logs, Metriken, Alerts)
Security-relevante Blockierungen werden zentral erfasst:
cron_auth_rejected,cron_secret_missingcron_execution_locked,cron_duplicate_ignoredpdf_ssrf_blockedrate_limit_blockedip_access_deniedcsrf_blockedauth_blockedrequest_validation_failed
Metrik-/Alert-Konfiguration:
SECURITY_METRICS_WINDOW_MS=300000
SECURITY_ALERT_COOLDOWN_MS=900000
SECURITY_ALERT_THRESHOLD_DEFAULT=25
SECURITY_ALERT_THRESHOLD_CRON_AUTH_REJECTED=10
SECURITY_ALERT_THRESHOLD_PDF_SSRF_BLOCKED=5
SECURITY_ALERT_THRESHOLD_RATE_LIMIT_BLOCKED=50
Bei Schwellwert-Verletzungen werden Alerts über den vorhandenen Alert-Service dispatcht.
Secret Lifecycle Monitoring
Zusätzlich zur Startup-Validierung werden Secret-Risiken im Monitoring-Snapshot erfasst:
- fehlende Pflicht-Secrets (
missing) - ablaufende Secrets (
expiringSoon) - abgelaufene Secrets (
expired) - überfällige Rotation (
rotationOverdue)
Relevante Variablen:
SECRET_EXPIRY_WARNING_DAYS=14
SECRET_ROTATION_MAX_DAYS=90
PAYLOAD_SECRET_ROTATED_AT=2026-02-01T00:00:00Z
PAYLOAD_SECRET_EXPIRES_AT=2026-08-01T00:00:00Z
CRON_SECRET_ROTATED_AT=2026-02-01T00:00:00Z
CRON_SECRET_EXPIRES_AT=2026-08-01T00:00:00Z
Wenn *_EXPIRES_AT gesetzt und bereits abgelaufen ist, schlägt der Serverstart in Production fehl (fail-closed).
Runbook: docs/anleitungen/SECRET_ROTATION_RUNBOOK.md
Data Masking
Automatisch maskierte Felder:
- password, passwd, pwd
- token, accessToken, refreshToken
- apiKey, api_key
- secret, clientSecret
- credentials
- privateKey, private_key
- smtpPassword, smtp_pass
Verwendung:
import { maskObject, createSafeLogger } from '@/lib/security'
// Objekte maskieren
const safeData = maskObject(sensitiveObject)
// Safe Logger verwenden
const logger = createSafeLogger('MyModule')
logger.info('User action', { password: 'secret' })
// Output: { password: '[REDACTED]' }
Pattern-Erkennung:
- JWT Tokens: Header bleibt, Payload/Signature maskiert
- Connection Strings: Passwort maskiert
- Private Keys: Komplett ersetzt
Pre-Commit Hook
Secret Detection bei jedem Commit via scripts/detect-secrets.sh:
Installation:
ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit
Erkannte Patterns:
- API Keys und Tokens
- AWS Credentials
- Private Keys
- Passwörter in Code
- SMTP Credentials
- Database Connection Strings
- JWT Tokens
- Webhook URLs (Slack, Discord)
- GitHub/SendGrid/Stripe Tokens
Ignorierte Dateien:
*.min.js,*.min.csspackage-lock.json,pnpm-lock.yaml*.md,*.txt*.example,*.sample*.spec.ts,*.test.ts(Test-Dateien)
CI/CD Security
.github/workflows/security.yml:
| Job | Prüfung |
|---|---|
secrets |
Gitleaks Secret Scanning |
dependencies |
npm audit, Dependency Check |
codeql |
Static Code Analysis |
security-tests |
177 Security Unit & Integration Tests |
Test Suite
Ausführung:
# Alle Security-Tests
pnpm test:security
# Nur Unit-Tests
pnpm test:unit
Abdeckung:
| Test-Datei | Tests | Bereich |
|---|---|---|
rate-limiter.unit.spec.ts |
24 | Limiter, Tracking, Reset, TRUST_PROXY |
csrf.unit.spec.ts |
34 | Token, Validierung, Origin |
ip-allowlist.unit.spec.ts |
35 | CIDR, Wildcards, Endpoints, TRUST_PROXY |
data-masking.unit.spec.ts |
41 | Felder, Patterns, Rekursion |
security-api.int.spec.ts |
33 | API-Integration |
cron-auth.unit.spec.ts |
neu | Cron-Auth, Idempotency, Lock |
pdf-url-validation.unit.spec.ts |
neu | SSRF-Validierung |
users-access.unit.spec.ts |
neu | Users-Update/Field Access Regression |
newsletter-unsubscribe.unit.spec.ts |
neu | Token-Rotation & Replay-Schutz |
Empfehlungen
Production Checklist
TRUST_PROXY=truesetzen (Pflicht hinter Reverse-Proxy wie Caddy)CSRF_SECREToderPAYLOAD_SECRETsetzen (Server startet nicht ohne)CRON_SECRETsetzen (Pflicht für Cron-Endpunkte in Production)SCHEDULER_MODE=externalin Production setzenCRON_LOCK_TTL_MSundCRON_IDEMPOTENCY_TTL_MSprüfen- Alle
BLOCKED_IPSfür bekannte Angreifer setzen SEND_EMAIL_ALLOWED_IPSauf vertrauenswürdige IPs beschränkenGENERATE_PDF_ALLOWED_IPSauf vertrauenswürdige IPs beschränkenADMIN_ALLOWED_IPSauf Office/VPN-IPs setzenPDF_ALLOWED_HOSTSfür erlaubte externe Render-Ziele konfigurierenENABLE_IN_PROCESS_SCHEDULERnur für lokale Entwicklung aktivierenSECRET_EXPIRY_WARNING_DAYS/SECRET_ROTATION_MAX_DAYSpassend setzen- Redis für verteiltes Rate Limiting konfigurieren
- Pre-Commit Hook aktivieren
Monitoring
- Rate Limit Violations loggen (429 Responses)
- CSRF Validation Failures überwachen
- Blocked IP Attempts tracken
- Login-Fehlversuche (authLimiter) alertieren
- Cron-Auth-Rejections und Cron-Lock-Kollisionen alertieren
- SSRF-Blocks bei PDF-Generierung monitoren
- Secret-Lifecycle (
external.secrets) täglich prüfen
Repo-History-Scan
Für History-Scans steht scripts/security/history-scan.sh bereit:
./scripts/security/history-scan.sh
Aktueller Befund: docs/reports/2026-02-17-history-scan.md
Custom Login Route
Das Admin Panel verwendet eine Custom Login Route (src/app/(payload)/api/users/login/route.ts) mit folgenden Features:
- Audit-Logging: Jeder Login-Versuch wird in AuditLogs protokolliert
- Rate-Limiting: 5 Versuche pro 15 Minuten (authLimiter)
- Browser-Redirect: Sichere Weiterleitung nach erfolgreichem Login
- Content-Type Support:
- JSON (
application/json) - FormData mit
_payloadJSON-Feld (Payload Admin Panel Format) - Standard FormData (
multipart/form-data) - URL-encoded (
application/x-www-form-urlencoded)
- JSON (
Browser Form Redirect:
POST /api/users/login?redirect=/admin/collections/posts
Content-Type: application/x-www-form-urlencoded
email=admin@example.com&password=secret
Redirect-Validierung:
- Nur relative Pfade erlaubt (
/admin/...) - Externe URLs werden blockiert
- Protocol-Handler (
javascript:,data:) abgelehnt - Default:
/adminbei fehlendem/ungültigem Redirect
Sicherheitsaspekte:
- Passwort wird nie in Logs/Responses exponiert
- Fehlgeschlagene Login-Versuche werden mit IP und User-Agent geloggt
- Rate-Limiting verhindert Brute-Force-Angriffe
- Open Redirect Prevention durch URL-Validierung
Änderungshistorie
| Datum | Änderung |
|---|---|
| 17.02.2026 | Security-Hardening: Users-Update fail-closed, isSuperAdmin field-protected, Cron-Auth fail-closed, PDF-SSRF-Schutz, Newsletter-Unsubscribe ohne ID-Enumeration |
| 29.12.2025 | Dokumentation aktualisiert: Custom Login Page Abschnitt entfernt (wurde am 27.12.2025 entfernt) |
| 17.12.2025 | Security-Audit Fixes: TRUST_PROXY für IP-Header-Spoofing, CSRF_SECRET Pflicht in Production, IP-Allowlist Startup-Warnungen, Tests auf 177 erweitert |
| 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support |
| 08.12.2025 | Security Test Suite (143 Tests) |
| 07.12.2025 | Rate Limiter, CSRF, IP Allowlist, Data Masking |
| 07.12.2025 | Pre-Commit Hook, GitHub Actions Workflow |
Dateien
| Pfad | Beschreibung |
|---|---|
src/lib/security/rate-limiter.ts |
Rate Limiting mit Redis/Memory |
src/lib/security/ip-allowlist.ts |
IP-basierte Zugriffskontrolle |
src/lib/security/csrf.ts |
CSRF Token Generation & Validation |
src/lib/security/data-masking.ts |
Sensitive Data Masking |
src/app/(payload)/api/users/login/route.ts |
Custom Login API mit Audit |
scripts/detect-secrets.sh |
Pre-Commit Secret Detection |
.github/workflows/security.yml |
CI Security Scanning |
tests/unit/security/ |
Security Unit Tests |
tests/int/security-api.int.spec.ts |
Security Integration Tests |