From 24ea067cd9ebeefcbbf2af60ea7eef172dd45a7b Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Tue, 16 Dec 2025 21:48:58 +0000 Subject: [PATCH 1/3] fix(ci): add timeouts to prevent 6-hour hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 30-minute job-level timeouts for Tests and E2E Tests - Add step-level timeouts: 10min unit tests, 15min integration/e2e - Add vitest testTimeout (30s) and hookTimeout (30s) Prevents infinite retry loops from blocking CI for hours. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 5 +++++ vitest.config.mts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79dd4f5..c93216c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,7 @@ jobs: name: Tests runs-on: ubuntu-latest needs: [lint, typecheck] + timeout-minutes: 30 # Prevent 6-hour hangs services: postgres: image: postgres:17 @@ -116,6 +117,7 @@ jobs: - name: Run Unit Tests run: pnpm test:unit + timeout-minutes: 10 env: CSRF_SECRET: test-csrf-secret PAYLOAD_SECRET: test-payload-secret @@ -148,6 +150,7 @@ jobs: - name: Run Integration Tests run: pnpm test:int + timeout-minutes: 15 env: CSRF_SECRET: test-csrf-secret PAYLOAD_SECRET: test-payload-secret @@ -227,6 +230,7 @@ jobs: name: E2E Tests runs-on: ubuntu-latest needs: [build] + timeout-minutes: 30 # Prevent 6-hour hangs services: postgres: image: postgres:17 @@ -290,6 +294,7 @@ jobs: - name: Run E2E tests run: pnpm test:e2e + timeout-minutes: 15 env: CI: true CSRF_SECRET: e2e-csrf-secret-placeholder diff --git a/vitest.config.mts b/vitest.config.mts index be40568..2dacadb 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,6 +7,8 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], + testTimeout: 30000, // 30 seconds per test + hookTimeout: 30000, // 30 seconds for beforeAll/afterAll include: [ 'tests/int/**/*.int.spec.ts', 'tests/unit/**/*.unit.spec.ts', From cf14584d0c0815236ce110aec7ba710d69d46477 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Tue, 16 Dec 2025 22:48:45 +0000 Subject: [PATCH 2/3] docs: update TODO.md with CI timeout improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add changelog entry for 16.12.2025 - Document job-level and step-level timeouts - Document vitest timeout configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/anleitungen/TODO.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md index f6fcedd..3bf0a04 100644 --- a/docs/anleitungen/TODO.md +++ b/docs/anleitungen/TODO.md @@ -222,12 +222,21 @@ --- -*Letzte Aktualisierung: 15.12.2025* +*Letzte Aktualisierung: 16.12.2025* --- ## Changelog +### 16.12.2025 +- **CI/CD Pipeline stabilisiert:** + - Job-Level Timeouts (30 Minuten) für Tests und E2E hinzugefügt + - Step-Level Timeouts: Unit Tests (10min), Integration Tests (15min), E2E Tests (15min) + - Vitest testTimeout (30s) und hookTimeout (30s) konfiguriert + - Verhindert 6-Stunden-Hänger bei fehlgeschlagenen DB-Verbindungen + - drizzle-kit als Dev-Dependency hinzugefügt (früherer Commit) + - drizzle-kit push statt Migrations für CI (früherer Commit) + ### 15.12.2025 - **Data Retention System implementiert:** - Automatische Datenbereinigung für DSGVO-Compliance From 63b97c14f25849dbddad6bba11e8eb9828e889c8 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Thu, 18 Dec 2025 05:06:15 +0000 Subject: [PATCH 3/3] feat(security): enhance CSRF, IP allowlist, and rate limiter with strict production checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 111 ++++++---- docs/anleitungen/SECURITY.md | 41 +++- docs/anleitungen/TODO.md | 14 +- src/app/(payload)/admin/login/page.tsx | 196 ++++++++++++++++++ src/app/(payload)/api/users/login/route.ts | 61 +++++- src/collections/Users.ts | 13 +- src/lib/security/csrf.ts | 48 ++++- src/lib/security/ip-allowlist.ts | 89 +++++++- src/lib/security/rate-limiter.ts | 53 ++++- src/middleware.ts | 13 +- src/payload.config.ts | 4 +- tests/int/security-api.int.spec.ts | 4 + tests/unit/security/ip-allowlist.unit.spec.ts | 127 +++++++++--- tests/unit/security/rate-limiter.unit.spec.ts | 96 ++++++--- 14 files changed, 741 insertions(+), 129 deletions(-) create mode 100644 src/app/(payload)/admin/login/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 331e90b..538fc7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -SEND_EMAIL_ALLOWED_IPS= # Optional: Komma-separierte IPs/CIDRs -BLOCKED_IPS= # Optional: Global geblockte IPs +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= +GET https://pl.porwoll.tech/api/newsletter/confirm?token= # Abmeldung (via Link aus E-Mail) -GET https://pl.c2sgmbh.de/api/newsletter/unsubscribe?token= +GET https://pl.porwoll.tech/api/newsletter/unsubscribe?token= ``` **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 diff --git a/docs/anleitungen/SECURITY.md b/docs/anleitungen/SECURITY.md index 7b29482..2d7881a 100644 --- a/docs/anleitungen/SECURITY.md +++ b/docs/anleitungen/SECURITY.md @@ -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 | diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md index 3bf0a04..a52be40 100644 --- a/docs/anleitungen/TODO.md +++ b/docs/anleitungen/TODO.md @@ -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 diff --git a/src/app/(payload)/admin/login/page.tsx b/src/app/(payload)/admin/login/page.tsx new file mode 100644 index 0000000..dfb3bff --- /dev/null +++ b/src/app/(payload)/admin/login/page.tsx @@ -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 +}) { + 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 ( + + + + + Anmelden - Payload + + + +
+
+ + + + + + +
+

Anmelden

+ + {error && ( +
+ {error === 'invalid' ? 'E-Mail oder Passwort ist falsch.' : error} +
+ )} + +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + + ) +} diff --git a/src/app/(payload)/api/users/login/route.ts b/src/app/(payload)/api/users/login/route.ts index b79648c..b56cac5 100644 --- a/src/app/(payload)/api/users/login/route.ts +++ b/src/app/(payload)/api/users/login/route.ts @@ -90,6 +90,14 @@ export async function POST(req: NextRequest): Promise { 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 { 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 { }) // 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 { `[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( { diff --git a/src/collections/Users.ts b/src/collections/Users.ts index 643dace..96e1fd5 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -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], diff --git a/src/lib/security/csrf.ts b/src/lib/security/csrf.ts index 1694a23..0555bae 100644 --- a/src/lib/security/csrf.ts +++ b/src/lib/security/csrf.ts @@ -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://')) { diff --git a/src/lib/security/ip-allowlist.ts b/src/lib/security/ip-allowlist.ts index 1193ac8..f2d0e37 100644 --- a/src/lib/security/ip-allowlist.ts +++ b/src/lib/security/ip-allowlist.ts @@ -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 { - const forwarded = req.headers.get('x-forwarded-for') - if (forwarded) { - return forwarded.split(',')[0]?.trim() || 'unknown' + // 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' + } + + 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 = { }, } +// ============================================================================ +// 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 diff --git a/src/lib/security/rate-limiter.ts b/src/lib/security/rate-limiter.ts index ca4e722..f5686c7 100644 --- a/src/lib/security/rate-limiter.ts +++ b/src/lib/security/rate-limiter.ts @@ -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 { - const forwarded = headers.get('x-forwarded-for') - if (forwarded) { - return forwarded.split(',')[0]?.trim() || 'unknown' + 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' + } + + 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' } /** diff --git a/src/middleware.ts b/src/middleware.ts index 18a7ba1..03671e4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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', + ], } diff --git a/src/payload.config.ts b/src/payload.config.ts index cedf595..2fa98f1 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/tests/int/security-api.int.spec.ts b/tests/int/security-api.int.spec.ts index 2a75dea..5eee447 100644 --- a/tests/int/security-api.int.spec.ts +++ b/tests/int/security-api.int.spec.ts @@ -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, diff --git a/tests/unit/security/ip-allowlist.unit.spec.ts b/tests/unit/security/ip-allowlist.unit.spec.ts index a403035..7e8f9b7 100644 --- a/tests/unit/security/ip-allowlist.unit.spec.ts +++ b/tests/unit/security/ip-allowlist.unit.spec.ts @@ -25,57 +25,97 @@ describe('IP Allowlist', () => { return import('@/lib/security/ip-allowlist') } - it('extracts IP from x-forwarded-for header', 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', - }, + describe('with TRUST_PROXY=true', () => { + beforeEach(() => { + vi.stubEnv('TRUST_PROXY', 'true') }) - 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 { getClientIpFromRequest } = await getModule() + const ip = getClientIpFromRequest(req) - const req = new NextRequest('https://example.com/api/test', { - headers: { - 'x-real-ip': '192.168.1.100', - }, + expect(ip).toBe('203.0.113.50') }) - 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 { getClientIpFromRequest } = await getModule() + const ip = getClientIpFromRequest(req) - const req = new NextRequest('https://example.com/api/test', { - headers: { - 'x-forwarded-for': '10.0.0.1', - 'x-real-ip': '10.0.0.2', - }, + expect(ip).toBe('192.168.1.100') }) - 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 () => { - const { getClientIpFromRequest } = await getModule() + describe('without TRUST_PROXY (default - prevents IP spoofing)', () => { + 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') } + // 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', () => { diff --git a/tests/unit/security/rate-limiter.unit.spec.ts b/tests/unit/security/rate-limiter.unit.spec.ts index af2bb4e..f195f9b 100644 --- a/tests/unit/security/rate-limiter.unit.spec.ts +++ b/tests/unit/security/rate-limiter.unit.spec.ts @@ -151,49 +151,87 @@ describe('Rate Limiter', () => { }) describe('getClientIp', () => { - 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') + describe('with TRUST_PROXY=true', () => { + beforeEach(() => { + 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', () => { - const headers = new Headers() - headers.set('x-real-ip', '192.168.1.100') + describe('without TRUST_PROXY (default)', () => { + beforeEach(() => { + 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 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) - 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 headers = new Headers() + const ip = getClientIp(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 headers = new Headers() - headers.set('x-forwarded-for', ' 192.168.1.1 , 10.0.0.1') + const ip = getClientIp(headers) - const ip = getClientIp(headers) - - expect(ip).toBe('192.168.1.1') + expect(ip).toBe('direct-connection') + }) }) })