mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +00:00
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:
parent
cf14584d0c
commit
63b97c14f2
14 changed files with 741 additions and 129 deletions
111
CLAUDE.md
111
CLAUDE.md
|
|
@ -115,8 +115,8 @@ Internet → 37.24.237.181 → Caddy (443) → Payload (3000)
|
||||||
# Datenbank (Passwort in ~/.pgpass oder als Umgebungsvariable)
|
# Datenbank (Passwort in ~/.pgpass oder als Umgebungsvariable)
|
||||||
DATABASE_URI=postgresql://payload:${DB_PASSWORD}@127.0.0.1:6432/payload_db
|
DATABASE_URI=postgresql://payload:${DB_PASSWORD}@127.0.0.1:6432/payload_db
|
||||||
PAYLOAD_SECRET=a53b254070d3fffd2b5cfcc3
|
PAYLOAD_SECRET=a53b254070d3fffd2b5cfcc3
|
||||||
PAYLOAD_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
|
PAYLOAD_PUBLIC_SERVER_URL=https://pl.porwoll.tech
|
||||||
NEXT_PUBLIC_SERVER_URL=https://pl.c2sgmbh.de
|
NEXT_PUBLIC_SERVER_URL=https://pl.porwoll.tech
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
|
@ -133,11 +133,17 @@ SMTP_FROM_NAME=Payload CMS
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
CSRF_SECRET=your-csrf-secret
|
CSRF_SECRET=your-csrf-secret # PFLICHT in Production (oder PAYLOAD_SECRET)
|
||||||
SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs
|
TRUST_PROXY=true # PFLICHT hinter Reverse-Proxy (Caddy/Nginx)
|
||||||
BLOCKED_IPS= # Optional: Global geblockte IPs
|
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 Connection Pooling
|
||||||
|
|
||||||
PgBouncer läuft auf dem App-Server und pooled Datenbankverbindungen:
|
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
|
1. Code ändern
|
||||||
2. `pnpm build`
|
2. `pnpm build`
|
||||||
3. `pm2 restart payload`
|
3. `pm2 restart payload`
|
||||||
4. Testen unter https://pl.c2sgmbh.de/admin
|
4. Testen unter https://pl.porwoll.tech/admin
|
||||||
|
|
||||||
## Bekannte Besonderheiten
|
## Bekannte Besonderheiten
|
||||||
|
|
||||||
|
|
@ -289,23 +295,36 @@ PGPASSWORD="$DB_PASSWORD" psql -h 10.10.181.101 -U payload -d payload_db -c "\dt
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- **Admin Panel:** https://pl.c2sgmbh.de/admin
|
- **Admin Panel:** https://pl.porwoll.tech/admin
|
||||||
- **API:** https://pl.c2sgmbh.de/api
|
- **API:** https://pl.porwoll.tech/api
|
||||||
- **API-Dokumentation (Swagger UI):** https://pl.c2sgmbh.de/api/docs
|
- **API-Dokumentation (Swagger UI):** https://pl.porwoll.tech/api/docs
|
||||||
- **OpenAPI JSON:** https://pl.c2sgmbh.de/api/openapi.json
|
- **OpenAPI JSON:** https://pl.porwoll.tech/api/openapi.json
|
||||||
- **E-Mail API:** https://pl.c2sgmbh.de/api/send-email (POST, Auth erforderlich)
|
- **E-Mail API:** https://pl.porwoll.tech/api/send-email (POST, Auth erforderlich)
|
||||||
- **Test-E-Mail:** https://pl.c2sgmbh.de/api/test-email (POST, Admin erforderlich)
|
- **Test-E-Mail:** https://pl.porwoll.tech/api/test-email (POST, Admin erforderlich)
|
||||||
- **E-Mail Stats:** https://pl.c2sgmbh.de/api/email-logs/stats (GET, Auth erforderlich)
|
- **E-Mail Stats:** https://pl.porwoll.tech/api/email-logs/stats (GET, Auth erforderlich)
|
||||||
- **PDF-Generierung:** https://pl.c2sgmbh.de/api/generate-pdf (POST/GET, Auth erforderlich)
|
- **PDF-Generierung:** https://pl.porwoll.tech/api/generate-pdf (POST/GET, Auth erforderlich)
|
||||||
- **Newsletter Anmeldung:** https://pl.c2sgmbh.de/api/newsletter/subscribe (POST, öffentlich)
|
- **Newsletter Anmeldung:** https://pl.porwoll.tech/api/newsletter/subscribe (POST, öffentlich)
|
||||||
- **Newsletter Bestätigung:** https://pl.c2sgmbh.de/api/newsletter/confirm (GET/POST)
|
- **Newsletter Bestätigung:** https://pl.porwoll.tech/api/newsletter/confirm (GET/POST)
|
||||||
- **Newsletter Abmeldung:** https://pl.c2sgmbh.de/api/newsletter/unsubscribe (GET/POST)
|
- **Newsletter Abmeldung:** https://pl.porwoll.tech/api/newsletter/unsubscribe (GET/POST)
|
||||||
- **Timeline API:** https://pl.c2sgmbh.de/api/timelines (GET, öffentlich, tenant required)
|
- **Timeline API:** https://pl.porwoll.tech/api/timelines (GET, öffentlich, tenant required)
|
||||||
- **Workflows API:** https://pl.c2sgmbh.de/api/workflows (GET, öffentlich, tenant required)
|
- **Workflows API:** https://pl.porwoll.tech/api/workflows (GET, öffentlich, tenant required)
|
||||||
- **Data Retention API:** https://pl.c2sgmbh.de/api/retention (GET/POST, Super-Admin erforderlich)
|
- **Data Retention API:** https://pl.porwoll.tech/api/retention (GET/POST, Super-Admin erforderlich)
|
||||||
|
|
||||||
## Security-Features
|
## 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
|
### Rate-Limiting
|
||||||
Zentraler Rate-Limiter mit vordefinierten Limits:
|
Zentraler Rate-Limiter mit vordefinierten Limits:
|
||||||
- `publicApi`: 60 Requests/Minute
|
- `publicApi`: 60 Requests/Minute
|
||||||
|
|
@ -314,16 +333,22 @@ Zentraler Rate-Limiter mit vordefinierten Limits:
|
||||||
- `search`: 30 Requests/Minute
|
- `search`: 30 Requests/Minute
|
||||||
- `form`: 5 Requests/10 Minuten
|
- `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
|
### CSRF-Schutz
|
||||||
- Double Submit Cookie Pattern
|
- Double Submit Cookie Pattern
|
||||||
- Origin-Header-Validierung
|
- Origin-Header-Validierung
|
||||||
- Token-Endpoint: `GET /api/csrf-token`
|
- Token-Endpoint: `GET /api/csrf-token`
|
||||||
- Admin-Panel hat eigenen CSRF-Schutz
|
- Admin-Panel hat eigenen CSRF-Schutz
|
||||||
|
- **CSRF_SECRET oder PAYLOAD_SECRET ist in Production PFLICHT**
|
||||||
|
- Server startet nicht ohne Secret in Production
|
||||||
|
|
||||||
### IP-Allowlist
|
### 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.*.*`)
|
- Unterstützt IPs, CIDRs (`192.168.1.0/24`) und Wildcards (`10.10.*.*`)
|
||||||
- Globale Blocklist via `BLOCKED_IPS`
|
- Globale Blocklist via `BLOCKED_IPS`
|
||||||
|
- **Warnung:** Leere Allowlists erlauben standardmäßig alle IPs
|
||||||
|
|
||||||
### Data-Masking
|
### Data-Masking
|
||||||
- Automatische Maskierung sensibler Daten in Logs
|
- Automatische Maskierung sensibler Daten in Logs
|
||||||
|
|
@ -349,7 +374,7 @@ Tenants → email → fromAddress, fromName, replyTo
|
||||||
|
|
||||||
**API-Endpoint `/api/send-email`:**
|
**API-Endpoint `/api/send-email`:**
|
||||||
```bash
|
```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 "Content-Type: application/json" \
|
||||||
-H "Cookie: payload-token=..." \
|
-H "Cookie: payload-token=..." \
|
||||||
-d '{
|
-d '{
|
||||||
|
|
@ -381,7 +406,7 @@ DSGVO-konformes Newsletter-System mit Double Opt-In:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Newsletter-Anmeldung
|
# 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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"email": "user@example.com",
|
"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)
|
# 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)
|
# 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:**
|
**Features:**
|
||||||
|
|
@ -445,7 +470,7 @@ PDF-Generierung erfolgt über Playwright (HTML/URL zu PDF):
|
||||||
**API-Endpoint `/api/generate-pdf`:**
|
**API-Endpoint `/api/generate-pdf`:**
|
||||||
```bash
|
```bash
|
||||||
# PDF aus HTML generieren
|
# 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 "Content-Type: application/json" \
|
||||||
-H "Cookie: payload-token=..." \
|
-H "Cookie: payload-token=..." \
|
||||||
-d '{
|
-d '{
|
||||||
|
|
@ -455,7 +480,7 @@ curl -X POST https://pl.c2sgmbh.de/api/generate-pdf \
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Job-Status abfragen
|
# 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=..."
|
-H "Cookie: payload-token=..."
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -516,32 +541,32 @@ QUEUE_ENABLE_RETENTION_SCHEDULER=true
|
||||||
|
|
||||||
**GET - Konfiguration abrufen:**
|
**GET - Konfiguration abrufen:**
|
||||||
```bash
|
```bash
|
||||||
curl https://pl.c2sgmbh.de/api/retention \
|
curl https://pl.porwoll.tech/api/retention \
|
||||||
-H "Cookie: payload-token=..."
|
-H "Cookie: payload-token=..."
|
||||||
```
|
```
|
||||||
|
|
||||||
**GET - Job-Status abfragen:**
|
**GET - Job-Status abfragen:**
|
||||||
```bash
|
```bash
|
||||||
curl "https://pl.c2sgmbh.de/api/retention?jobId=abc123" \
|
curl "https://pl.porwoll.tech/api/retention?jobId=abc123" \
|
||||||
-H "Cookie: payload-token=..."
|
-H "Cookie: payload-token=..."
|
||||||
```
|
```
|
||||||
|
|
||||||
**POST - Manuellen Job auslösen:**
|
**POST - Manuellen Job auslösen:**
|
||||||
```bash
|
```bash
|
||||||
# Vollständige Retention (alle Policies + Media-Orphans)
|
# 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 "Content-Type: application/json" \
|
||||||
-H "Cookie: payload-token=..." \
|
-H "Cookie: payload-token=..." \
|
||||||
-d '{"type": "full"}'
|
-d '{"type": "full"}'
|
||||||
|
|
||||||
# Einzelne Collection bereinigen
|
# 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 "Content-Type: application/json" \
|
||||||
-H "Cookie: payload-token=..." \
|
-H "Cookie: payload-token=..." \
|
||||||
-d '{"type": "collection", "collection": "email-logs"}'
|
-d '{"type": "collection", "collection": "email-logs"}'
|
||||||
|
|
||||||
# Nur Media-Orphans bereinigen
|
# 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 "Content-Type: application/json" \
|
||||||
-H "Cookie: payload-token=..." \
|
-H "Cookie: payload-token=..." \
|
||||||
-d '{"type": "media-orphans"}'
|
-d '{"type": "media-orphans"}'
|
||||||
|
|
@ -770,16 +795,16 @@ Dedizierte Collection für komplexe chronologische Darstellungen:
|
||||||
**API-Endpoint:**
|
**API-Endpoint:**
|
||||||
```bash
|
```bash
|
||||||
# Liste aller Timelines eines Tenants
|
# 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
|
# 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
|
# 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
|
# 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
|
## Workflows Collection
|
||||||
|
|
@ -827,19 +852,19 @@ Workflow
|
||||||
**API-Endpoint:**
|
**API-Endpoint:**
|
||||||
```bash
|
```bash
|
||||||
# Liste aller Workflows eines Tenants
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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:**
|
**Validierung:**
|
||||||
|
|
@ -934,7 +959,7 @@ Automatisches Deployment auf Staging-Server bei Push auf `develop`:
|
||||||
| `workflow_dispatch` | Manuelles Deployment (optional: skip_tests) |
|
| `workflow_dispatch` | Manuelles Deployment (optional: skip_tests) |
|
||||||
|
|
||||||
**Deployment-Ziel:**
|
**Deployment-Ziel:**
|
||||||
- **URL:** https://pl.c2sgmbh.de
|
- **URL:** https://pl.porwoll.tech
|
||||||
- **Server:** 37.24.237.181 (sv-payload)
|
- **Server:** 37.24.237.181 (sv-payload)
|
||||||
|
|
||||||
**Ablauf:**
|
**Ablauf:**
|
||||||
|
|
@ -947,7 +972,7 @@ Automatisches Deployment auf Staging-Server bei Push auf `develop`:
|
||||||
|
|
||||||
**Manuelles Staging-Deployment:**
|
**Manuelles Staging-Deployment:**
|
||||||
```bash
|
```bash
|
||||||
# Auf dem Staging-Server (pl.c2sgmbh.de)
|
# Auf dem Staging-Server (pl.porwoll.tech)
|
||||||
./scripts/deploy-staging.sh
|
./scripts/deploy-staging.sh
|
||||||
|
|
||||||
# Mit Optionen
|
# Mit Optionen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Security-Richtlinien - Payload CMS Multi-Tenant
|
# Security-Richtlinien - Payload CMS Multi-Tenant
|
||||||
|
|
||||||
> Letzte Aktualisierung: 09.12.2025
|
> Letzte Aktualisierung: 17.12.2025
|
||||||
|
|
||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
|
|
@ -58,10 +58,25 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
### IP Allowlist/Blocklist
|
### 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:**
|
**Environment-Variablen:**
|
||||||
|
|
||||||
| Variable | Zweck | Format |
|
| Variable | Zweck | Format |
|
||||||
|----------|-------|--------|
|
|----------|-------|--------|
|
||||||
|
| `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer |
|
||||||
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
|
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
|
||||||
| `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard |
|
| `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard |
|
||||||
| `ADMIN_ALLOWED_IPS` | Admin-Panel 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`
|
**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:**
|
**Frontend-Integration:**
|
||||||
```typescript
|
```typescript
|
||||||
// 1. Token abrufen
|
// 1. Token abrufen
|
||||||
|
|
@ -208,7 +235,7 @@ ln -sf ../../scripts/detect-secrets.sh .git/hooks/pre-commit
|
||||||
| `secrets` | Gitleaks Secret Scanning |
|
| `secrets` | Gitleaks Secret Scanning |
|
||||||
| `dependencies` | npm audit, Dependency Check |
|
| `dependencies` | npm audit, Dependency Check |
|
||||||
| `codeql` | Static Code Analysis |
|
| `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 |
|
| 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 |
|
| `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 |
|
| `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
|
### 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
|
- [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen
|
||||||
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
|
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken
|
||||||
- [ ] `ADMIN_ALLOWED_IPS` auf Office/VPN-IPs setzen
|
- [ ] `ADMIN_ALLOWED_IPS` auf Office/VPN-IPs setzen
|
||||||
- [ ] Redis für verteiltes Rate Limiting konfigurieren
|
- [ ] Redis für verteiltes Rate Limiting konfigurieren
|
||||||
- [ ] CSRF_SECRET in .env setzen (min. 32 Zeichen)
|
|
||||||
- [ ] Pre-Commit Hook aktivieren
|
- [ ] Pre-Commit Hook aktivieren
|
||||||
|
|
||||||
### Monitoring
|
### Monitoring
|
||||||
|
|
@ -278,6 +306,7 @@ Das Admin Panel verwendet eine Custom Login Route (`src/app/(payload)/api/users/
|
||||||
|
|
||||||
| Datum | Änderung |
|
| 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 |
|
| 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support |
|
||||||
| 08.12.2025 | Security Test Suite (143 Tests) |
|
| 08.12.2025 | Security Test Suite (143 Tests) |
|
||||||
| 07.12.2025 | Rate Limiter, CSRF, IP Allowlist, Data Masking |
|
| 07.12.2025 | Rate Limiter, CSRF, IP Allowlist, Data Masking |
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@
|
||||||
|
|
||||||
- [ ] TypeScript Strict Mode aktivieren
|
- [ ] TypeScript Strict Mode aktivieren
|
||||||
- [x] E2E Tests für kritische Flows
|
- [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
|
- [ ] Performance-Audit der Datenbank-Queries
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -222,12 +222,22 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Letzte Aktualisierung: 16.12.2025*
|
*Letzte Aktualisierung: 17.12.2025*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Changelog
|
## 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
|
### 16.12.2025
|
||||||
- **CI/CD Pipeline stabilisiert:**
|
- **CI/CD Pipeline stabilisiert:**
|
||||||
- Job-Level Timeouts (30 Minuten) für Tests und E2E hinzugefügt
|
- Job-Level Timeouts (30 Minuten) für Tests und E2E hinzugefügt
|
||||||
|
|
|
||||||
196
src/app/(payload)/admin/login/page.tsx
Normal file
196
src/app/(payload)/admin/login/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
|
||||||
const contentType = req.headers.get('content-type') || ''
|
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')) {
|
if (contentType.includes('multipart/form-data')) {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
|
|
||||||
|
|
@ -110,10 +118,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
email = formData.get('email')?.toString()
|
email = formData.get('email')?.toString()
|
||||||
password = formData.get('password')?.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')) {
|
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
email = formData.get('email')?.toString()
|
email = formData.get('email')?.toString()
|
||||||
password = formData.get('password')?.toString()
|
password = formData.get('password')?.toString()
|
||||||
|
redirectUrl = formData.get('redirect')?.toString()
|
||||||
} else {
|
} else {
|
||||||
// Default: JSON
|
// Default: JSON
|
||||||
const body = await req.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
|
// 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({
|
const response = NextResponse.json({
|
||||||
message: 'Auth Passed',
|
message: 'Auth Passed',
|
||||||
user: result.user,
|
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})`,
|
`[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)
|
// Response im Payload-Format (wie der native Endpoint)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,18 @@ export const Users: CollectionConfig = {
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'email',
|
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: {
|
hooks: {
|
||||||
afterChange: [auditUserAfterChange],
|
afterChange: [auditUserAfterChange],
|
||||||
afterDelete: [auditUserAfterDelete],
|
afterDelete: [auditUserAfterDelete],
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,54 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { randomBytes, createHmac } from 'crypto'
|
import { randomBytes, createHmac } from 'crypto'
|
||||||
|
|
||||||
// CSRF-Token Secret (sollte in .env sein)
|
// CSRF-Token Secret - MUSS in Production konfiguriert sein
|
||||||
const CSRF_SECRET = process.env.CSRF_SECRET || process.env.PAYLOAD_SECRET || 'default-csrf-secret'
|
|
||||||
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
|
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
|
||||||
const CSRF_COOKIE_NAME = 'csrf-token'
|
const CSRF_COOKIE_NAME = 'csrf-token'
|
||||||
const TOKEN_EXPIRY_MS = 60 * 60 * 1000 // 1 Stunde
|
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
|
* Generiert ein neues CSRF-Token
|
||||||
*/
|
*/
|
||||||
|
|
@ -100,7 +142,7 @@ export function validateOrigin(origin: string | null): { valid: boolean; reason?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subdomain-Matching für Produktions-Domains
|
// 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) {
|
for (const domain of productionDomains) {
|
||||||
if (origin.endsWith(domain) && origin.startsWith('https://')) {
|
if (origin.endsWith(domain) && origin.startsWith('https://')) {
|
||||||
|
|
|
||||||
|
|
@ -66,16 +66,43 @@ function parseIpList(value: string | undefined): string[] {
|
||||||
.filter((ip) => ip.length > 0)
|
.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
|
* 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 {
|
export function getClientIpFromRequest(req: NextRequest): string {
|
||||||
const forwarded = req.headers.get('x-forwarded-for')
|
// Nur Proxy-Headers vertrauen wenn explizit konfiguriert
|
||||||
if (forwarded) {
|
if (shouldTrustProxy()) {
|
||||||
return forwarded.split(',')[0]?.trim() || 'unknown'
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = req.headers.get('x-real-ip')
|
||||||
|
if (realIp) {
|
||||||
|
return realIp.trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return req.headers.get('x-real-ip') || 'unknown'
|
// 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
|
* Prüft ob eine IP für einen bestimmten Endpoint erlaubt ist
|
||||||
*/
|
*/
|
||||||
|
|
@ -116,6 +194,9 @@ export function isIpAllowed(
|
||||||
ip: string,
|
ip: string,
|
||||||
endpoint: keyof typeof allowlistConfigs,
|
endpoint: keyof typeof allowlistConfigs,
|
||||||
): { allowed: boolean; reason?: string } {
|
): { allowed: boolean; reason?: string } {
|
||||||
|
// Startup-Warnungen beim ersten Aufruf loggen
|
||||||
|
logStartupWarnings()
|
||||||
|
|
||||||
const config = allowlistConfigs[endpoint]
|
const config = allowlistConfigs[endpoint]
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { allowed: true } // Unbekannter Endpoint = erlaubt
|
return { allowed: true } // Unbekannter Endpoint = erlaubt
|
||||||
|
|
|
||||||
|
|
@ -321,16 +321,61 @@ export const strictLimiter = createRateLimiter({
|
||||||
// Hilfsfunktionen
|
// 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
|
* 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 {
|
export function getClientIp(headers: Headers): string {
|
||||||
const forwarded = headers.get('x-forwarded-for')
|
logTrustProxyStatus()
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(',')[0]?.trim() || 'unknown'
|
// 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = headers.get('x-real-ip')
|
||||||
|
if (realIp) {
|
||||||
|
return realIp.trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers.get('x-real-ip') || 'unknown'
|
// 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'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@ function getLocaleFromCookie(request: NextRequest): Locale | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
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
|
// Skip locale routing for excluded paths and public files
|
||||||
if (
|
if (
|
||||||
|
|
@ -97,5 +102,9 @@ export function middleware(request: NextRequest) {
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Match all paths except static files and API routes
|
// 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',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
export default buildConfig({
|
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: {
|
admin: {
|
||||||
user: Users.slug,
|
user: Users.slug,
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -147,6 +147,7 @@ export default buildConfig({
|
||||||
'https://dev.zh3.de',
|
'https://dev.zh3.de',
|
||||||
'https://porwoll.de',
|
'https://porwoll.de',
|
||||||
'https://www.porwoll.de',
|
'https://www.porwoll.de',
|
||||||
|
'https://pl.porwoll.tech',
|
||||||
],
|
],
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
csrf: [
|
csrf: [
|
||||||
|
|
@ -157,6 +158,7 @@ export default buildConfig({
|
||||||
'https://dev.zh3.de',
|
'https://dev.zh3.de',
|
||||||
'https://porwoll.de',
|
'https://porwoll.de',
|
||||||
'https://www.porwoll.de',
|
'https://www.porwoll.de',
|
||||||
|
'https://pl.porwoll.tech',
|
||||||
],
|
],
|
||||||
collections: [
|
collections: [
|
||||||
Users,
|
Users,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
// Enable CSRF validation in CI by setting BYPASS_CSRF=false
|
// Enable CSRF validation in CI by setting BYPASS_CSRF=false
|
||||||
// This must be set before any module imports that read this variable
|
// This must be set before any module imports that read this variable
|
||||||
process.env.BYPASS_CSRF = 'false'
|
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 {
|
import {
|
||||||
generateTestCsrfToken,
|
generateTestCsrfToken,
|
||||||
generateExpiredCsrfToken,
|
generateExpiredCsrfToken,
|
||||||
|
|
|
||||||
|
|
@ -25,57 +25,97 @@ describe('IP Allowlist', () => {
|
||||||
return import('@/lib/security/ip-allowlist')
|
return import('@/lib/security/ip-allowlist')
|
||||||
}
|
}
|
||||||
|
|
||||||
it('extracts IP from x-forwarded-for header', async () => {
|
describe('with TRUST_PROXY=true', () => {
|
||||||
const { getClientIpFromRequest } = await getModule()
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('TRUST_PROXY', 'true')
|
||||||
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)
|
it('extracts IP from x-forwarded-for header', async () => {
|
||||||
|
const { getClientIpFromRequest } = await getModule()
|
||||||
|
|
||||||
expect(ip).toBe('203.0.113.50')
|
const req = new NextRequest('https://example.com/api/test', {
|
||||||
})
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
it('extracts IP from x-real-ip header', async () => {
|
const ip = getClientIpFromRequest(req)
|
||||||
const { getClientIpFromRequest } = await getModule()
|
|
||||||
|
|
||||||
const req = new NextRequest('https://example.com/api/test', {
|
expect(ip).toBe('203.0.113.50')
|
||||||
headers: {
|
|
||||||
'x-real-ip': '192.168.1.100',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ip = getClientIpFromRequest(req)
|
it('extracts IP from x-real-ip header', async () => {
|
||||||
|
const { getClientIpFromRequest } = await getModule()
|
||||||
|
|
||||||
expect(ip).toBe('192.168.1.100')
|
const req = new NextRequest('https://example.com/api/test', {
|
||||||
})
|
headers: {
|
||||||
|
'x-real-ip': '192.168.1.100',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
it('prefers x-forwarded-for over x-real-ip', async () => {
|
const ip = getClientIpFromRequest(req)
|
||||||
const { getClientIpFromRequest } = await getModule()
|
|
||||||
|
|
||||||
const req = new NextRequest('https://example.com/api/test', {
|
expect(ip).toBe('192.168.1.100')
|
||||||
headers: {
|
|
||||||
'x-forwarded-for': '10.0.0.1',
|
|
||||||
'x-real-ip': '10.0.0.2',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ip = getClientIpFromRequest(req)
|
it('prefers x-forwarded-for over x-real-ip', async () => {
|
||||||
|
const { getClientIpFromRequest } = await getModule()
|
||||||
|
|
||||||
expect(ip).toBe('10.0.0.1')
|
const req = new NextRequest('https://example.com/api/test', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '10.0.0.1',
|
||||||
|
'x-real-ip': '10.0.0.2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ip = getClientIpFromRequest(req)
|
||||||
|
|
||||||
|
expect(ip).toBe('10.0.0.1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns unknown for missing headers', async () => {
|
describe('without TRUST_PROXY (default - prevents IP spoofing)', () => {
|
||||||
const { getClientIpFromRequest } = await getModule()
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('TRUST_PROXY', '')
|
||||||
|
})
|
||||||
|
|
||||||
const req = new NextRequest('https://example.com/api/test')
|
it('ignores x-forwarded-for header to prevent IP spoofing', async () => {
|
||||||
|
const { getClientIpFromRequest } = await getModule()
|
||||||
|
|
||||||
const ip = getClientIpFromRequest(req)
|
const req = new NextRequest('https://example.com/api/test', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.50, 70.41.3.18',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
expect(ip).toBe('unknown')
|
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('direct-connection')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -194,6 +234,11 @@ describe('IP Allowlist', () => {
|
||||||
return import('@/lib/security/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 () => {
|
it('blocks if IP is on blocklist', async () => {
|
||||||
vi.stubEnv('BLOCKED_IPS', '192.168.1.100')
|
vi.stubEnv('BLOCKED_IPS', '192.168.1.100')
|
||||||
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24')
|
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')
|
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', () => {
|
describe('CIDR Matching', () => {
|
||||||
|
|
|
||||||
|
|
@ -151,49 +151,87 @@ describe('Rate Limiter', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getClientIp', () => {
|
describe('getClientIp', () => {
|
||||||
it('extracts IP from x-forwarded-for header', () => {
|
describe('with TRUST_PROXY=true', () => {
|
||||||
const headers = new Headers()
|
beforeEach(() => {
|
||||||
headers.set('x-forwarded-for', '203.0.113.50, 70.41.3.18, 150.172.238.178')
|
vi.stubEnv('TRUST_PROXY', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
const ip = getClientIp(headers)
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
expect(ip).toBe('203.0.113.50')
|
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')
|
||||||
|
|
||||||
|
const ip = getClientIp(headers)
|
||||||
|
|
||||||
|
expect(ip).toBe('203.0.113.50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts IP from x-real-ip header', () => {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set('x-real-ip', '192.168.1.100')
|
||||||
|
|
||||||
|
const ip = getClientIp(headers)
|
||||||
|
|
||||||
|
expect(ip).toBe('192.168.1.100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers x-forwarded-for over x-real-ip', () => {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set('x-forwarded-for', '10.0.0.1')
|
||||||
|
headers.set('x-real-ip', '10.0.0.2')
|
||||||
|
|
||||||
|
const ip = getClientIp(headers)
|
||||||
|
|
||||||
|
expect(ip).toBe('10.0.0.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace from forwarded IPs', () => {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1')
|
||||||
|
|
||||||
|
const ip = getClientIp(headers)
|
||||||
|
|
||||||
|
expect(ip).toBe('192.168.1.1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extracts IP from x-real-ip header', () => {
|
describe('without TRUST_PROXY (default)', () => {
|
||||||
const headers = new Headers()
|
beforeEach(() => {
|
||||||
headers.set('x-real-ip', '192.168.1.100')
|
vi.stubEnv('TRUST_PROXY', '')
|
||||||
|
})
|
||||||
|
|
||||||
const ip = getClientIp(headers)
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
expect(ip).toBe('192.168.1.100')
|
it('ignores x-forwarded-for header to prevent IP spoofing', () => {
|
||||||
})
|
const headers = new Headers()
|
||||||
|
headers.set('x-forwarded-for', '203.0.113.50')
|
||||||
|
|
||||||
it('prefers x-forwarded-for over x-real-ip', () => {
|
const ip = getClientIp(headers)
|
||||||
const headers = new Headers()
|
|
||||||
headers.set('x-forwarded-for', '10.0.0.1')
|
|
||||||
headers.set('x-real-ip', '10.0.0.2')
|
|
||||||
|
|
||||||
const ip = getClientIp(headers)
|
expect(ip).toBe('direct-connection')
|
||||||
|
})
|
||||||
|
|
||||||
expect(ip).toBe('10.0.0.1')
|
it('ignores x-real-ip header to prevent IP spoofing', () => {
|
||||||
})
|
const headers = new Headers()
|
||||||
|
headers.set('x-real-ip', '192.168.1.100')
|
||||||
|
|
||||||
it('returns unknown for missing headers', () => {
|
const ip = getClientIp(headers)
|
||||||
const headers = new Headers()
|
|
||||||
|
|
||||||
const ip = getClientIp(headers)
|
expect(ip).toBe('direct-connection')
|
||||||
|
})
|
||||||
|
|
||||||
expect(ip).toBe('unknown')
|
it('returns direct-connection for missing headers', () => {
|
||||||
})
|
const headers = new Headers()
|
||||||
|
|
||||||
it('trims whitespace from forwarded IPs', () => {
|
const ip = getClientIp(headers)
|
||||||
const headers = new Headers()
|
|
||||||
headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1')
|
|
||||||
|
|
||||||
const ip = getClientIp(headers)
|
expect(ip).toBe('direct-connection')
|
||||||
|
})
|
||||||
expect(ip).toBe('192.168.1.1')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue