feat(security): enhance CSRF, IP allowlist, and rate limiter with strict production checks

- CSRF: Require CSRF_SECRET in production, throw error on missing secret
- IP Allowlist: TRUST_PROXY must be explicitly set to 'true' for proxy headers
- Rate Limiter: Add proper proxy trust handling for client IP detection
- Login: Add browser form redirect support with safe URL validation
- Add custom admin login page with styled form
- Update CLAUDE.md with TRUST_PROXY documentation
- Update tests for new security behavior

BREAKING: Server will not start in production without CSRF_SECRET or PAYLOAD_SECRET

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-18 05:06:15 +00:00
parent cf14584d0c
commit 63b97c14f2
14 changed files with 741 additions and 129 deletions

107
CLAUDE.md
View file

@ -115,8 +115,8 @@ Internet → 37.24.237.181 → Caddy (443) → Payload (3000)
# Datenbank (Passwort in ~/.pgpass oder als Umgebungsvariable)
DATABASE_URI=postgresql://payload:${DB_PASSWORD}@127.0.0.1:6432/payload_db
PAYLOAD_SECRET=a53b254070d3fffd2b5cfcc3
PAYLOAD_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
NEXT_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
PAYLOAD_PUBLIC_SERVER_URL=https://pl.porwoll.tech
NEXT_PUBLIC_SERVER_URL=https://pl.porwoll.tech
NODE_ENV=production
PORT=3000
@ -133,11 +133,17 @@ SMTP_FROM_NAME=Payload CMS
REDIS_URL=redis://localhost:6379
# Security
CSRF_SECRET=your-csrf-secret
CSRF_SECRET=your-csrf-secret # PFLICHT in Production (oder PAYLOAD_SECRET)
TRUST_PROXY=true # PFLICHT hinter Reverse-Proxy (Caddy/Nginx)
SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs
ADMIN_ALLOWED_IPS= # Optional: IP-Beschränkung für Admin-Panel
BLOCKED_IPS= # Optional: Global geblockte IPs
```
> **Wichtig:** `TRUST_PROXY=true` muss gesetzt sein wenn die App hinter einem Reverse-Proxy
> (wie Caddy) läuft. Ohne diese Einstellung funktionieren IP-basierte Sicherheitsfunktionen
> (Rate-Limiting, IP-Allowlists, Blocklists) nicht korrekt.
## PgBouncer Connection Pooling
PgBouncer läuft auf dem App-Server und pooled Datenbankverbindungen:
@ -245,7 +251,7 @@ scripts/backup/setup-backup.sh # Backup-System einricht
1. Code ändern
2. `pnpm build`
3. `pm2 restart payload`
4. Testen unter https://pl.c2sgmbh.de/admin
4. Testen unter https://pl.porwoll.tech/admin
## Bekannte Besonderheiten
@ -289,23 +295,36 @@ PGPASSWORD="$DB_PASSWORD" psql -h 10.10.181.101 -U payload -d payload_db -c "\dt
## URLs
- **Admin Panel:** https://pl.c2sgmbh.de/admin
- **API:** https://pl.c2sgmbh.de/api
- **API-Dokumentation (Swagger UI):** https://pl.c2sgmbh.de/api/docs
- **OpenAPI JSON:** https://pl.c2sgmbh.de/api/openapi.json
- **E-Mail API:** https://pl.c2sgmbh.de/api/send-email (POST, Auth erforderlich)
- **Test-E-Mail:** https://pl.c2sgmbh.de/api/test-email (POST, Admin erforderlich)
- **E-Mail Stats:** https://pl.c2sgmbh.de/api/email-logs/stats (GET, Auth erforderlich)
- **PDF-Generierung:** https://pl.c2sgmbh.de/api/generate-pdf (POST/GET, Auth erforderlich)
- **Newsletter Anmeldung:** https://pl.c2sgmbh.de/api/newsletter/subscribe (POST, öffentlich)
- **Newsletter Bestätigung:** https://pl.c2sgmbh.de/api/newsletter/confirm (GET/POST)
- **Newsletter Abmeldung:** https://pl.c2sgmbh.de/api/newsletter/unsubscribe (GET/POST)
- **Timeline API:** https://pl.c2sgmbh.de/api/timelines (GET, öffentlich, tenant required)
- **Workflows API:** https://pl.c2sgmbh.de/api/workflows (GET, öffentlich, tenant required)
- **Data Retention API:** https://pl.c2sgmbh.de/api/retention (GET/POST, Super-Admin erforderlich)
- **Admin Panel:** https://pl.porwoll.tech/admin
- **API:** https://pl.porwoll.tech/api
- **API-Dokumentation (Swagger UI):** https://pl.porwoll.tech/api/docs
- **OpenAPI JSON:** https://pl.porwoll.tech/api/openapi.json
- **E-Mail API:** https://pl.porwoll.tech/api/send-email (POST, Auth erforderlich)
- **Test-E-Mail:** https://pl.porwoll.tech/api/test-email (POST, Admin erforderlich)
- **E-Mail Stats:** https://pl.porwoll.tech/api/email-logs/stats (GET, Auth erforderlich)
- **PDF-Generierung:** https://pl.porwoll.tech/api/generate-pdf (POST/GET, Auth erforderlich)
- **Newsletter Anmeldung:** https://pl.porwoll.tech/api/newsletter/subscribe (POST, öffentlich)
- **Newsletter Bestätigung:** https://pl.porwoll.tech/api/newsletter/confirm (GET/POST)
- **Newsletter Abmeldung:** https://pl.porwoll.tech/api/newsletter/unsubscribe (GET/POST)
- **Timeline API:** https://pl.porwoll.tech/api/timelines (GET, öffentlich, tenant required)
- **Workflows API:** https://pl.porwoll.tech/api/workflows (GET, öffentlich, tenant required)
- **Data Retention API:** https://pl.porwoll.tech/api/retention (GET/POST, Super-Admin erforderlich)
## Security-Features
### Proxy-Vertrauen (TRUST_PROXY)
**WICHTIG:** Wenn die App hinter einem Reverse-Proxy läuft (Caddy, Nginx, etc.),
muss `TRUST_PROXY=true` gesetzt sein:
```env
TRUST_PROXY=true
```
Ohne diese Einstellung:
- Werden `X-Forwarded-For` und `X-Real-IP` Header ignoriert
- Können Angreifer IP-basierte Sicherheitsmaßnahmen nicht umgehen
- Aber: Rate-Limiting und IP-Allowlists funktionieren nicht korrekt
### Rate-Limiting
Zentraler Rate-Limiter mit vordefinierten Limits:
- `publicApi`: 60 Requests/Minute
@ -314,16 +333,22 @@ Zentraler Rate-Limiter mit vordefinierten Limits:
- `search`: 30 Requests/Minute
- `form`: 5 Requests/10 Minuten
**Hinweis:** Rate-Limiting basiert auf Client-IP. Bei `TRUST_PROXY=false` wird
`direct-connection` als IP verwendet, was alle Clients zusammenfasst.
### CSRF-Schutz
- Double Submit Cookie Pattern
- Origin-Header-Validierung
- Token-Endpoint: `GET /api/csrf-token`
- Admin-Panel hat eigenen CSRF-Schutz
- **CSRF_SECRET oder PAYLOAD_SECRET ist in Production PFLICHT**
- Server startet nicht ohne Secret in Production
### IP-Allowlist
- Konfigurierbar via `SEND_EMAIL_ALLOWED_IPS`
- Konfigurierbar via `SEND_EMAIL_ALLOWED_IPS`, `ADMIN_ALLOWED_IPS`
- Unterstützt IPs, CIDRs (`192.168.1.0/24`) und Wildcards (`10.10.*.*`)
- Globale Blocklist via `BLOCKED_IPS`
- **Warnung:** Leere Allowlists erlauben standardmäßig alle IPs
### Data-Masking
- Automatische Maskierung sensibler Daten in Logs
@ -349,7 +374,7 @@ Tenants → email → fromAddress, fromName, replyTo
**API-Endpoint `/api/send-email`:**
```bash
curl -X POST https://pl.c2sgmbh.de/api/send-email \
curl -X POST https://pl.porwoll.tech/api/send-email \
-H "Content-Type: application/json" \
-H "Cookie: payload-token=..." \
-d '{
@ -381,7 +406,7 @@ DSGVO-konformes Newsletter-System mit Double Opt-In:
```bash
# Newsletter-Anmeldung
curl -X POST https://pl.c2sgmbh.de/api/newsletter/subscribe \
curl -X POST https://pl.porwoll.tech/api/newsletter/subscribe \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
@ -391,10 +416,10 @@ curl -X POST https://pl.c2sgmbh.de/api/newsletter/subscribe \
}'
# Bestätigung (via Link aus E-Mail)
GET https://pl.c2sgmbh.de/api/newsletter/confirm?token=<uuid>
GET https://pl.porwoll.tech/api/newsletter/confirm?token=<uuid>
# Abmeldung (via Link aus E-Mail)
GET https://pl.c2sgmbh.de/api/newsletter/unsubscribe?token=<uuid>
GET https://pl.porwoll.tech/api/newsletter/unsubscribe?token=<uuid>
```
**Features:**
@ -445,7 +470,7 @@ PDF-Generierung erfolgt über Playwright (HTML/URL zu PDF):
**API-Endpoint `/api/generate-pdf`:**
```bash
# PDF aus HTML generieren
curl -X POST https://pl.c2sgmbh.de/api/generate-pdf \
curl -X POST https://pl.porwoll.tech/api/generate-pdf \
-H "Content-Type: application/json" \
-H "Cookie: payload-token=..." \
-d '{
@ -455,7 +480,7 @@ curl -X POST https://pl.c2sgmbh.de/api/generate-pdf \
}'
# Job-Status abfragen
curl "https://pl.c2sgmbh.de/api/generate-pdf?jobId=abc123" \
curl "https://pl.porwoll.tech/api/generate-pdf?jobId=abc123" \
-H "Cookie: payload-token=..."
```
@ -516,32 +541,32 @@ QUEUE_ENABLE_RETENTION_SCHEDULER=true
**GET - Konfiguration abrufen:**
```bash
curl https://pl.c2sgmbh.de/api/retention \
curl https://pl.porwoll.tech/api/retention \
-H "Cookie: payload-token=..."
```
**GET - Job-Status abfragen:**
```bash
curl "https://pl.c2sgmbh.de/api/retention?jobId=abc123" \
curl "https://pl.porwoll.tech/api/retention?jobId=abc123" \
-H "Cookie: payload-token=..."
```
**POST - Manuellen Job auslösen:**
```bash
# Vollständige Retention (alle Policies + Media-Orphans)
curl -X POST https://pl.c2sgmbh.de/api/retention \
curl -X POST https://pl.porwoll.tech/api/retention \
-H "Content-Type: application/json" \
-H "Cookie: payload-token=..." \
-d '{"type": "full"}'
# Einzelne Collection bereinigen
curl -X POST https://pl.c2sgmbh.de/api/retention \
curl -X POST https://pl.porwoll.tech/api/retention \
-H "Content-Type: application/json" \
-H "Cookie: payload-token=..." \
-d '{"type": "collection", "collection": "email-logs"}'
# Nur Media-Orphans bereinigen
curl -X POST https://pl.c2sgmbh.de/api/retention \
curl -X POST https://pl.porwoll.tech/api/retention \
-H "Content-Type: application/json" \
-H "Cookie: payload-token=..." \
-d '{"type": "media-orphans"}'
@ -770,16 +795,16 @@ Dedizierte Collection für komplexe chronologische Darstellungen:
**API-Endpoint:**
```bash
# Liste aller Timelines eines Tenants
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1"
curl "https://pl.porwoll.tech/api/timelines?tenant=1"
# Nach Typ filtern
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&type=history"
curl "https://pl.porwoll.tech/api/timelines?tenant=1&type=history"
# Einzelne Timeline
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&slug=company-history"
curl "https://pl.porwoll.tech/api/timelines?tenant=1&slug=company-history"
# Mit Sprache
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&locale=en"
curl "https://pl.porwoll.tech/api/timelines?tenant=1&locale=en"
```
## Workflows Collection
@ -827,19 +852,19 @@ Workflow
**API-Endpoint:**
```bash
# Liste aller Workflows eines Tenants
curl "https://pl.c2sgmbh.de/api/workflows?tenant=1"
curl "https://pl.porwoll.tech/api/workflows?tenant=1"
# Nach Typ filtern
curl "https://pl.c2sgmbh.de/api/workflows?tenant=1&type=project"
curl "https://pl.porwoll.tech/api/workflows?tenant=1&type=project"
# Nach Komplexität filtern
curl "https://pl.c2sgmbh.de/api/workflows?tenant=1&complexity=medium"
curl "https://pl.porwoll.tech/api/workflows?tenant=1&complexity=medium"
# Einzelner Workflow
curl "https://pl.c2sgmbh.de/api/workflows?tenant=1&slug=web-project"
curl "https://pl.porwoll.tech/api/workflows?tenant=1&slug=web-project"
# Mit Sprache
curl "https://pl.c2sgmbh.de/api/workflows?tenant=1&locale=en"
curl "https://pl.porwoll.tech/api/workflows?tenant=1&locale=en"
```
**Validierung:**
@ -934,7 +959,7 @@ Automatisches Deployment auf Staging-Server bei Push auf `develop`:
| `workflow_dispatch` | Manuelles Deployment (optional: skip_tests) |
**Deployment-Ziel:**
- **URL:** https://pl.c2sgmbh.de
- **URL:** https://pl.porwoll.tech
- **Server:** 37.24.237.181 (sv-payload)
**Ablauf:**
@ -947,7 +972,7 @@ Automatisches Deployment auf Staging-Server bei Push auf `develop`:
**Manuelles Staging-Deployment:**
```bash
# Auf dem Staging-Server (pl.c2sgmbh.de)
# Auf dem Staging-Server (pl.porwoll.tech)
./scripts/deploy-staging.sh
# Mit Optionen

View file

@ -1,6 +1,6 @@
# Security-Richtlinien - Payload CMS Multi-Tenant
> Letzte Aktualisierung: 09.12.2025
> Letzte Aktualisierung: 17.12.2025
## Übersicht
@ -58,10 +58,25 @@ export async function GET(req: NextRequest) {
### IP Allowlist/Blocklist
> **WICHTIG: TRUST_PROXY Konfiguration**
>
> Wenn die Anwendung hinter einem Reverse-Proxy (Caddy, Nginx, etc.) läuft,
> **muss** `TRUST_PROXY=true` gesetzt werden. Ohne diese Einstellung werden
> `X-Forwarded-For` und `X-Real-IP` Header ignoriert, um IP-Spoofing zu verhindern.
>
> ```bash
> # In .env setzen wenn hinter Reverse-Proxy:
> TRUST_PROXY=true
> ```
>
> Ohne `TRUST_PROXY=true` wird für alle Requests `direct-connection` als 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 |
| `ADMIN_ALLOWED_IPS` | Admin-Panel Allowlist | IP, CIDR, Wildcard |
@ -104,6 +119,18 @@ if (!ipCheck.allowed) {
**Token-Endpoint:** `GET /api/csrf-token`
> **WICHTIG: CSRF_SECRET in Production**
>
> In Production **muss** entweder `CSRF_SECRET` oder `PAYLOAD_SECRET` konfiguriert sein.
> Ohne Secret startet der Server nicht. Ein vorhersagbares Secret würde Angreifern
> erlauben, gültige CSRF-Tokens offline zu generieren.
>
> ```bash
> # In .env setzen:
> CSRF_SECRET=mindestens-32-zeichen-langes-geheimnis
> # ODER PAYLOAD_SECRET wird als Fallback verwendet
> ```
**Frontend-Integration:**
```typescript
// 1. Token abrufen
@ -208,7 +235,7 @@ ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit
| `secrets` | Gitleaks Secret Scanning |
| `dependencies` | npm audit, Dependency Check |
| `codeql` | Static Code Analysis |
| `security-tests` | 143 Security Unit & Integration Tests |
| `security-tests` | 177 Security Unit & Integration Tests |
---
@ -227,11 +254,11 @@ pnpm test:unit
| Test-Datei | Tests | Bereich |
|------------|-------|---------|
| `rate-limiter.unit.spec.ts` | 21 | Limiter, Tracking, Reset |
| `rate-limiter.unit.spec.ts` | 24 | Limiter, Tracking, Reset, TRUST_PROXY |
| `csrf.unit.spec.ts` | 34 | Token, Validierung, Origin |
| `ip-allowlist.unit.spec.ts` | 29 | CIDR, Wildcards, Endpoints |
| `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` | 18 | API-Integration |
| `security-api.int.spec.ts` | 33 | API-Integration |
---
@ -239,11 +266,12 @@ pnpm test:unit
### Production Checklist
- [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy)
- [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne)
- [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
- [ ] `ADMIN_ALLOWED_IPS` auf Office/VPN-IPs setzen
- [ ] Redis für verteiltes Rate Limiting konfigurieren
- [ ] CSRF_SECRET in .env setzen (min. 32 Zeichen)
- [ ] Pre-Commit Hook aktivieren
### Monitoring
@ -278,6 +306,7 @@ Das Admin Panel verwendet eine Custom Login Route (`src/app/(payload)/api/users/
| Datum | Änderung |
|-------|----------|
| 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 |

View file

@ -166,7 +166,7 @@
- [ ] TypeScript Strict Mode aktivieren
- [x] E2E Tests für kritische Flows
- [ ] Code-Review für Security-relevante Bereiche
- [x] Code-Review für Security-relevante Bereiche *(erledigt: 17.12.2025)*
- [ ] Performance-Audit der Datenbank-Queries
---
@ -222,12 +222,22 @@
---
*Letzte Aktualisierung: 16.12.2025*
*Letzte Aktualisierung: 17.12.2025*
---
## Changelog
### 17.12.2025
- **Security Code-Review abgeschlossen:**
- **IP Header Spoofing behoben:** `X-Forwarded-For`/`X-Real-IP` werden nur bei `TRUST_PROXY=true` vertraut
- **CSRF Secret-Fallback behoben:** Server startet nicht ohne `CSRF_SECRET` oder `PAYLOAD_SECRET` in Production
- **IP-Allowlist Warnungen:** Startup-Warnungen wenn Allowlists leer und `allowAllIfEmpty=true`
- Neue Umgebungsvariable: `TRUST_PROXY=true` (Pflicht hinter Reverse-Proxy)
- Dateien: `src/lib/security/rate-limiter.ts`, `src/lib/security/ip-allowlist.ts`, `src/lib/security/csrf.ts`
- Unit Tests und Integration Tests aktualisiert (177 Tests passed)
- CLAUDE.md Dokumentation aktualisiert
### 16.12.2025
- **CI/CD Pipeline stabilisiert:**
- Job-Level Timeouts (30 Minuten) für Tests und E2E hinzugefügt

View file

@ -0,0 +1,196 @@
/**
* Custom Login Page
*
* Komplett eigene Login-Seite um den Redirect-Loop-Bug in Payload zu umgehen.
* Diese Seite rendert ein einfaches Login-Formular das direkt mit der Payload API kommuniziert.
*/
import { headers, cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
type SearchParams = {
redirect?: string
error?: string
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const resolvedParams = await searchParams
const payload = await getPayload({ config: configPromise })
// Prüfe ob User bereits eingeloggt ist
const headersList = await headers()
const cookieStore = await cookies()
const token = cookieStore.get('payload-token')?.value
if (token) {
try {
const { user } = await payload.auth({ headers: headersList })
if (user) {
// User ist eingeloggt - weiterleiten
const redirectTo = resolvedParams.redirect || '/admin'
// Verhindere Redirect-Loop
if (!redirectTo.includes('/login')) {
redirect(redirectTo)
}
redirect('/admin')
}
} catch {
// Token ungültig - weiter zum Login
}
}
// Bestimme Redirect-Ziel (verhindere Loop)
let redirectTarget = resolvedParams.redirect || '/admin'
if (redirectTarget.includes('/login')) {
redirectTarget = '/admin'
}
const error = resolvedParams.error
return (
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anmelden - Payload</title>
<style>{`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f0f0f;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: #1a1a1a;
padding: 2rem;
border-radius: 8px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo svg {
width: 48px;
height: 48px;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
text-align: center;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #a0a0a0;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 4px;
background: #0f0f0f;
color: #fff;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #3b82f6;
}
button {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
background: #3b82f6;
color: #fff;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-top: 1rem;
}
button:hover {
background: #2563eb;
}
.error {
background: #dc2626;
color: #fff;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
`}</style>
</head>
<body>
<div className="login-container">
<div className="logo">
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 3L3 7.5V17.5L11.5 22L20 17.5V7.5L11.5 3Z" stroke="white" strokeWidth="2"/>
<path d="M11.5 12L3 7.5" stroke="white" strokeWidth="2"/>
<path d="M11.5 12V22" stroke="white" strokeWidth="2"/>
<path d="M11.5 12L20 7.5" stroke="white" strokeWidth="2"/>
</svg>
</div>
<h1>Anmelden</h1>
{error && (
<div className="error">
{error === 'invalid' ? 'E-Mail oder Passwort ist falsch.' : error}
</div>
)}
<form action="/api/users/login" method="POST">
<input type="hidden" name="redirect" value={redirectTarget} />
<div className="form-group">
<label htmlFor="email">E-Mail</label>
<input
type="email"
id="email"
name="email"
required
autoComplete="email"
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Passwort</label>
<input
type="password"
id="password"
name="password"
required
autoComplete="current-password"
/>
</div>
<button type="submit">Anmelden</button>
</form>
</div>
</body>
</html>
)
}

View file

@ -90,6 +90,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const contentType = req.headers.get('content-type') || ''
// Für Browser-Formulare: Redirect-Ziel extrahieren
let redirectUrl: string | undefined
// Prüfen ob dies ein Browser-Formular ist (nicht XHR/fetch)
const acceptHeader = req.headers.get('accept') || ''
const isBrowserForm =
acceptHeader.includes('text/html') && !req.headers.get('x-requested-with')
if (contentType.includes('multipart/form-data')) {
const formData = await req.formData()
@ -110,10 +118,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
email = formData.get('email')?.toString()
password = formData.get('password')?.toString()
}
// Redirect-URL für Browser-Formulare
redirectUrl = formData.get('redirect')?.toString()
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await req.formData()
email = formData.get('email')?.toString()
password = formData.get('password')?.toString()
redirectUrl = formData.get('redirect')?.toString()
} else {
// Default: JSON
const body = await req.json()
@ -142,7 +154,44 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
})
// Erfolgreicher Login - afterLogin Hook hat bereits geloggt
// Response im Payload-Format zurückgeben
// Für Browser-Formulare: Redirect mit gesetztem Cookie
if (isBrowserForm && redirectUrl) {
// Sicherheitscheck: Nur relative URLs oder URLs zur eigenen Domain erlauben
const serverUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL || ''
const isRelativeUrl = redirectUrl.startsWith('/')
const isSameDomain = serverUrl && redirectUrl.startsWith(serverUrl)
if (isRelativeUrl || isSameDomain) {
// Verhindere Redirect zu Login-Seite (Loop-Schutz)
let safeRedirect = redirectUrl
if (redirectUrl.includes('/login')) {
safeRedirect = '/admin'
}
const redirectResponse = NextResponse.redirect(
isRelativeUrl
? new URL(safeRedirect, req.url)
: new URL(safeRedirect),
302,
)
// Set the token cookie
if (result.token) {
redirectResponse.cookies.set('payload-token', result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: result.exp ? result.exp - Math.floor(Date.now() / 1000) : 7200,
})
}
return redirectResponse
}
}
// Für API-Requests: JSON-Response im Payload-Format
const response = NextResponse.json({
message: 'Auth Passed',
user: result.user,
@ -191,6 +240,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`,
)
// Für Browser-Formulare: Redirect zur Login-Seite mit Fehlermeldung
if (isBrowserForm) {
const loginUrl = new URL('/admin/login', req.url)
loginUrl.searchParams.set('error', 'invalid')
if (redirectUrl) {
loginUrl.searchParams.set('redirect', redirectUrl)
}
return NextResponse.redirect(loginUrl, 302)
}
// Response im Payload-Format (wie der native Endpoint)
return NextResponse.json(
{

View file

@ -11,7 +11,18 @@ export const Users: CollectionConfig = {
admin: {
useAsTitle: 'email',
},
auth: true,
auth: {
// Cookie-Konfiguration für Production hinter Reverse-Proxy (Cloudflare/Caddy)
cookies: {
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
domain: undefined, // Automatisch vom Browser gesetzt
},
// Sicherheitseinstellungen
lockTime: 10 * 60 * 1000, // 10 Minuten Lock nach max. Fehlversuchen
maxLoginAttempts: 5,
tokenExpiration: 7200, // 2 Stunden
},
hooks: {
afterChange: [auditUserAfterChange],
afterDelete: [auditUserAfterDelete],

View file

@ -8,12 +8,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { randomBytes, createHmac } from 'crypto'
// CSRF-Token Secret (sollte in .env sein)
const CSRF_SECRET = process.env.CSRF_SECRET || process.env.PAYLOAD_SECRET || 'default-csrf-secret'
// CSRF-Token Secret - MUSS in Production konfiguriert sein
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
const CSRF_COOKIE_NAME = 'csrf-token'
const TOKEN_EXPIRY_MS = 60 * 60 * 1000 // 1 Stunde
/**
* Ermittelt das CSRF-Secret mit strikter Validierung
*
* SECURITY: In Production wird ein Fehler geworfen wenn kein Secret konfiguriert ist.
* Ein vorhersagbares Secret würde Angreifern erlauben, CSRF-Tokens offline zu generieren.
*/
function getCsrfSecret(): string {
const secret = process.env.CSRF_SECRET || process.env.PAYLOAD_SECRET
if (secret) {
return secret
}
// In Production: Fehler werfen - CSRF ohne Secret ist wertlos
if (process.env.NODE_ENV === 'production') {
const errorMsg = '[SECURITY CRITICAL] CSRF_SECRET or PAYLOAD_SECRET must be configured in production! ' +
'Without a secret, CSRF tokens can be forged by attackers. ' +
'Set CSRF_SECRET in your environment variables.'
console.error(errorMsg)
throw new Error(errorMsg)
}
// In Development: Warnen und Development-Secret verwenden
console.warn(
'[Security Warning] No CSRF_SECRET or PAYLOAD_SECRET configured. ' +
'Using development-only secret. DO NOT use in production!'
)
return 'INSECURE-DEV-ONLY-' + randomBytes(16).toString('hex')
}
// Secret einmalig beim Import ermitteln (wirft in Production wenn nicht konfiguriert)
let CSRF_SECRET: string
try {
CSRF_SECRET = getCsrfSecret()
} catch (error) {
// In Production: Re-throw damit der Server nicht startet
if (process.env.NODE_ENV === 'production') {
throw error
}
// In Development: Fallback für Edge-Cases
CSRF_SECRET = 'INSECURE-DEV-FALLBACK'
}
/**
* Generiert ein neues CSRF-Token
*/
@ -100,7 +142,7 @@ export function validateOrigin(origin: string | null): { valid: boolean; reason?
}
// Subdomain-Matching für Produktions-Domains
const productionDomains = ['pl.c2sgmbh.de', 'porwoll.de', 'complexcaresolutions.de', 'gunshin.de']
const productionDomains = ['pl.porwoll.tech', 'porwoll.de', 'complexcaresolutions.de', 'gunshin.de']
for (const domain of productionDomains) {
if (origin.endsWith(domain) && origin.startsWith('https://')) {

View file

@ -66,16 +66,43 @@ function parseIpList(value: string | undefined): string[] {
.filter((ip) => ip.length > 0)
}
/**
* Prüft ob Proxy-Headers vertraut werden sollen
* TRUST_PROXY muss explizit auf 'true' gesetzt sein
*/
function shouldTrustProxy(): boolean {
return process.env.TRUST_PROXY === 'true'
}
/**
* Extrahiert die Client-IP aus einem NextRequest
*
* SECURITY: Nur bei TRUST_PROXY=true werden X-Forwarded-For/X-Real-IP Headers
* berücksichtigt. Ohne diese Einstellung könnten Angreifer ihre IP spoofing
* und IP-Allowlists/Blocklists umgehen.
*
* In einer Reverse-Proxy-Umgebung (z.B. hinter Caddy) sollte TRUST_PROXY=true
* gesetzt werden.
*/
export function getClientIpFromRequest(req: NextRequest): string {
// Nur Proxy-Headers vertrauen wenn explizit konfiguriert
if (shouldTrustProxy()) {
const forwarded = req.headers.get('x-forwarded-for')
if (forwarded) {
// Nimm nur die erste IP (vom Client-nächsten Proxy)
return forwarded.split(',')[0]?.trim() || 'unknown'
}
return req.headers.get('x-real-ip') || 'unknown'
const realIp = req.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
}
// Fallback: Ohne TRUST_PROXY können wir keine vertrauenswürdige IP ermitteln
// Gibt 'direct-connection' zurück um anzuzeigen, dass IP-basierte Prüfungen
// nicht zuverlässig sind
return 'direct-connection'
}
// ============================================================================
@ -109,6 +136,57 @@ const allowlistConfigs: Record<string, AllowlistConfig> = {
},
}
// ============================================================================
// Startup-Warnungen für Sicherheitskonfiguration
// ============================================================================
let startupWarningsLogged = false
/**
* Loggt Warnungen für leere Allowlists bei Startup
* SECURITY: Leere Allowlists mit allowAllIfEmpty=true bieten keinen Schutz
*/
function logStartupWarnings() {
if (startupWarningsLogged) return
startupWarningsLogged = true
const isProduction = process.env.NODE_ENV === 'production'
const warnings: string[] = []
for (const [key, config] of Object.entries(allowlistConfigs)) {
const configuredIps = parseIpList(process.env[config.envVar])
if (configuredIps.length === 0 && config.allowAllIfEmpty) {
warnings.push(
` - ${config.description} (${config.envVar}): Keine IPs konfiguriert, ALLE IPs erlaubt`
)
}
}
if (warnings.length > 0) {
const level = isProduction ? 'warn' : 'log'
console[level](
`[Security${isProduction ? ' Warning' : ''}] IP-Allowlists ohne Einschränkung:\n` +
warnings.join('\n') +
(isProduction
? '\n Konfigurieren Sie die entsprechenden Umgebungsvariablen für erhöhte Sicherheit.'
: '')
)
}
// Prüfe auch TRUST_PROXY
if (!shouldTrustProxy()) {
if (isProduction) {
console.warn(
'[Security Warning] TRUST_PROXY nicht konfiguriert. ' +
'IP-basierte Sicherheitsfunktionen (Rate-Limiting, Allowlists, Blocklists) ' +
'funktionieren nicht korrekt hinter einem Reverse-Proxy. ' +
'Setzen Sie TRUST_PROXY=true wenn Sie hinter Caddy/Nginx/etc. sind.'
)
}
}
}
/**
* Prüft ob eine IP für einen bestimmten Endpoint erlaubt ist
*/
@ -116,6 +194,9 @@ export function isIpAllowed(
ip: string,
endpoint: keyof typeof allowlistConfigs,
): { allowed: boolean; reason?: string } {
// Startup-Warnungen beim ersten Aufruf loggen
logStartupWarnings()
const config = allowlistConfigs[endpoint]
if (!config) {
return { allowed: true } // Unbekannter Endpoint = erlaubt

View file

@ -321,16 +321,61 @@ export const strictLimiter = createRateLimiter({
// Hilfsfunktionen
// ============================================================================
/**
* Prüft ob Proxy-Headers vertraut werden sollen
* TRUST_PROXY muss explizit auf 'true' gesetzt sein
*/
function shouldTrustProxy(): boolean {
return process.env.TRUST_PROXY === 'true'
}
// Log einmalig bei Startup ob TRUST_PROXY konfiguriert ist
let trustProxyLogged = false
function logTrustProxyStatus() {
if (trustProxyLogged) return
trustProxyLogged = true
if (shouldTrustProxy()) {
console.log('[Security] TRUST_PROXY=true: Trusting X-Forwarded-For/X-Real-IP headers')
} else {
console.log('[Security] TRUST_PROXY not set: Ignoring proxy headers (set TRUST_PROXY=true if behind reverse proxy)')
}
}
/**
* Extrahiert die Client-IP aus Request-Headers
*
* SECURITY: Nur bei TRUST_PROXY=true werden X-Forwarded-For/X-Real-IP Headers
* berücksichtigt. Ohne diese Einstellung könnten Angreifer ihre IP spoofing
* und Rate-Limits sowie IP-Blocklists umgehen.
*
* In einer Reverse-Proxy-Umgebung (z.B. hinter Caddy) sollte TRUST_PROXY=true
* gesetzt werden.
*/
export function getClientIp(headers: Headers): string {
logTrustProxyStatus()
// Nur Proxy-Headers vertrauen wenn explizit konfiguriert
if (shouldTrustProxy()) {
const forwarded = headers.get('x-forwarded-for')
if (forwarded) {
// Nimm nur die erste IP (vom Client-nächsten Proxy)
return forwarded.split(',')[0]?.trim() || 'unknown'
}
return headers.get('x-real-ip') || 'unknown'
const realIp = headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
}
// Fallback: In Next.js/Vercel wird die echte IP oft in x-real-ip gesetzt
// Aber ohne TRUST_PROXY geben wir 'direct' zurück um zu signalisieren
// dass keine vertrauenswürdige IP-Ermittlung möglich war
//
// HINWEIS: Für lokale Entwicklung oder wenn kein Proxy verwendet wird,
// sollten Rate-Limits auf anderen Identifiern basieren (z.B. User-ID)
return 'direct-connection'
}
/**

View file

@ -54,7 +54,12 @@ function getLocaleFromCookie(request: NextRequest): Locale | null {
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const { pathname, searchParams } = request.nextUrl
// Admin-Pfade komplett von Middleware ausschließen - Payload handled diese selbst
if (pathname.startsWith('/admin')) {
return NextResponse.next()
}
// Skip locale routing for excluded paths and public files
if (
@ -97,5 +102,9 @@ export function middleware(request: NextRequest) {
export const config = {
// Match all paths except static files and API routes
matcher: ['/((?!api|_next/static|_next/image|admin|favicon.ico).*)'],
// Explicitly include /admin/login for redirect loop prevention
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
'/admin/login',
],
}

View file

@ -94,7 +94,7 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.porwoll.tech',
admin: {
user: Users.slug,
components: {
@ -147,6 +147,7 @@ export default buildConfig({
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://pl.porwoll.tech',
],
// CSRF Protection
csrf: [
@ -157,6 +158,7 @@ export default buildConfig({
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://pl.porwoll.tech',
],
collections: [
Users,

View file

@ -11,6 +11,10 @@ import { NextRequest, NextResponse } from 'next/server'
// Enable CSRF validation in CI by setting BYPASS_CSRF=false
// This must be set before any module imports that read this variable
process.env.BYPASS_CSRF = 'false'
// Enable TRUST_PROXY to allow IP-based tests to work with x-forwarded-for headers
// In real deployment behind Caddy, this would be set in the environment
process.env.TRUST_PROXY = 'true'
import {
generateTestCsrfToken,
generateExpiredCsrfToken,

View file

@ -25,6 +25,11 @@ describe('IP Allowlist', () => {
return import('@/lib/security/ip-allowlist')
}
describe('with TRUST_PROXY=true', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', 'true')
})
it('extracts IP from x-forwarded-for header', async () => {
const { getClientIpFromRequest } = await getModule()
@ -67,15 +72,50 @@ describe('IP Allowlist', () => {
expect(ip).toBe('10.0.0.1')
})
})
it('returns unknown for missing headers', async () => {
describe('without TRUST_PROXY (default - prevents IP spoofing)', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', '')
})
it('ignores x-forwarded-for header to prevent IP spoofing', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('direct-connection')
})
it('ignores x-real-ip header to prevent IP spoofing', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-real-ip': '192.168.1.100',
},
})
const ip = getClientIpFromRequest(req)
expect(ip).toBe('direct-connection')
})
it('returns direct-connection for missing headers', async () => {
const { getClientIpFromRequest } = await getModule()
const req = new NextRequest('https://example.com/api/test')
const ip = getClientIpFromRequest(req)
expect(ip).toBe('unknown')
expect(ip).toBe('direct-connection')
})
})
})
@ -194,6 +234,11 @@ describe('IP Allowlist', () => {
return import('@/lib/security/ip-allowlist')
}
// These tests require TRUST_PROXY to extract IP from headers
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', 'true')
})
it('blocks if IP is on blocklist', async () => {
vi.stubEnv('BLOCKED_IPS', '192.168.1.100')
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24')
@ -255,6 +300,22 @@ describe('IP Allowlist', () => {
expect(result.ip).toBe('203.0.113.50')
})
it('returns direct-connection when TRUST_PROXY is not set', async () => {
vi.stubEnv('TRUST_PROXY', '')
const { validateIpAccess } = await getModule()
const req = new NextRequest('https://example.com/api/test', {
headers: {
'x-forwarded-for': '203.0.113.50',
},
})
const result = validateIpAccess(req, 'sendEmail')
// Without TRUST_PROXY, IP spoofing is prevented by ignoring headers
expect(result.ip).toBe('direct-connection')
})
})
describe('CIDR Matching', () => {

View file

@ -151,6 +151,15 @@ describe('Rate Limiter', () => {
})
describe('getClientIp', () => {
describe('with TRUST_PROXY=true', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', 'true')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('extracts IP from x-forwarded-for header', () => {
const headers = new Headers()
headers.set('x-forwarded-for', '203.0.113.50, 70.41.3.18, 150.172.238.178')
@ -179,14 +188,6 @@ describe('Rate Limiter', () => {
expect(ip).toBe('10.0.0.1')
})
it('returns unknown for missing headers', () => {
const headers = new Headers()
const ip = getClientIp(headers)
expect(ip).toBe('unknown')
})
it('trims whitespace from forwarded IPs', () => {
const headers = new Headers()
headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1')
@ -197,6 +198,43 @@ describe('Rate Limiter', () => {
})
})
describe('without TRUST_PROXY (default)', () => {
beforeEach(() => {
vi.stubEnv('TRUST_PROXY', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('ignores x-forwarded-for header to prevent IP spoofing', () => {
const headers = new Headers()
headers.set('x-forwarded-for', '203.0.113.50')
const ip = getClientIp(headers)
expect(ip).toBe('direct-connection')
})
it('ignores x-real-ip header to prevent IP spoofing', () => {
const headers = new Headers()
headers.set('x-real-ip', '192.168.1.100')
const ip = getClientIp(headers)
expect(ip).toBe('direct-connection')
})
it('returns direct-connection for missing headers', () => {
const headers = new Headers()
const ip = getClientIp(headers)
expect(ip).toBe('direct-connection')
})
})
})
describe('rateLimitHeaders', () => {
it('generates correct headers for allowed request', () => {
const result = {