From bb02128b283a7cffa0336bad96bdf86d2cd23b19 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Tue, 17 Feb 2026 11:47:55 +0000 Subject: [PATCH] fix: stabilize guard responses and validation typing --- docs/anleitungen/SECRET_ROTATION_RUNBOOK.md | 57 +++++++++++ docs/anleitungen/SECURITY.md | 97 ++++++++++++++++++- src/app/(payload)/api/auth/login/route.ts | 14 +-- src/app/(payload)/api/generate-pdf/route.ts | 11 ++- .../api/newsletter/unsubscribe/route.ts | 17 ++-- src/lib/security/api-guards.ts | 8 +- tests/unit/security/cron-auth.unit.spec.ts | 5 +- .../security/pdf-url-validation.unit.spec.ts | 3 + 8 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 docs/anleitungen/SECRET_ROTATION_RUNBOOK.md diff --git a/docs/anleitungen/SECRET_ROTATION_RUNBOOK.md b/docs/anleitungen/SECRET_ROTATION_RUNBOOK.md new file mode 100644 index 0000000..0ab06a0 --- /dev/null +++ b/docs/anleitungen/SECRET_ROTATION_RUNBOOK.md @@ -0,0 +1,57 @@ +# Secret Rotation Runbook + +> Version: 2026-02-17 + +## Ziel + +Standardisiertes Vorgehen für Rotation von Sicherheits- und Integrations-Secrets ohne Downtime. + +## Scope + +- `PAYLOAD_SECRET` +- `CRON_SECRET` +- `CSRF_SECRET` +- SMTP/OAuth/Redis Secrets (`SMTP_PASS`, `GOOGLE_CLIENT_SECRET`, `META_APP_SECRET`, `REDIS_PASSWORD`) + +## Vorbedingungen + +1. Wartungsfenster/Change-Freigabe vorhanden. +2. Backup und Rollback-Plan sind dokumentiert. +3. Neue Secret-Werte sind bereits im Secret-Manager hinterlegt. + +## Rotation Ablauf + +1. Neue Secrets generieren und im Secret-Manager speichern. +2. Alle betroffenen Deployments aktualisieren: + - CMS/App + - Queue Worker + - externe Cron-Jobs +3. Metadaten mitpflegen: + - `_ROTATED_AT=` + - optional `_EXPIRES_AT=` +4. Deployment durchführen. +5. Verifikation: + - Login/API funktionieren + - Cron-Endpunkte authentifizieren korrekt + - SMTP/OAuth/Redis Health ist grün + - Monitoring `external.secrets` zeigt keinen kritischen Zustand +6. Alte Secrets im Secret-Manager deaktivieren/entfernen. + +## Post-Rotation Checks + +```bash +pnpm -s typecheck +pnpm -s test:security +``` + +Optional: + +```bash +./scripts/security/history-scan.sh +``` + +## Notfall / Rollback + +1. Vorherigen Secret-Stand reaktivieren. +2. Services neu starten. +3. Fehlerursache analysieren (Format, propagierte Umgebungsvariablen, Cache/Stale Worker). diff --git a/docs/anleitungen/SECURITY.md b/docs/anleitungen/SECURITY.md index 80f7606..da5819a 100644 --- a/docs/anleitungen/SECURITY.md +++ b/docs/anleitungen/SECURITY.md @@ -26,6 +26,10 @@ Alle Security-Funktionen befinden sich in `src/lib/security/`: | CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz | | Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren | | Cron Auth | `cron-auth.ts` | Verbindliche Authentifizierung für Cron-Endpunkte | +| API Guards | `api-guards.ts` | Zentrale Guards für Auth/IP/CSRF/Rate-Limit | +| Cron Coordination | `cron-coordination.ts` | Multi-Instance-Lock + Idempotency für Cron-Ausführung | +| Security Observability | `security-observability.ts` | Strukturierte Security-Events, Metriken, Alert-Schwellen | +| Secrets Health | `secrets-health.ts` | Monitoring für fehlende/ablaufende/überfällige Secrets | ### Rate Limiter @@ -182,6 +186,10 @@ Alle `/api/cron/*` Endpunkte sind fail-closed abgesichert: - Ohne gültigen `Authorization: Bearer ` Header: `401` - Ohne gesetztes `CRON_SECRET`: `503` - Gilt für `GET`, `POST` und `HEAD` +- Mit `withCronExecution(...)`: + - Verteiltes Execution-Lock (Redis, In-Memory-Fallback) + - Idempotency-Key-Schutz über `x-idempotency-key` oder `?idempotencyKey=...` + - Doppelte Trigger werden mit `202` ignoriert In Production wird `CRON_SECRET` beim Startup validiert. @@ -201,6 +209,73 @@ PDF_ALLOWED_HOSTS=example.com,.example.com PDF_ALLOW_HTTP_URLS=false ``` +Geblockte SSRF-Versuche erzeugen Security-Events (`pdf_ssrf_blocked`) und können Alert-Schwellen auslösen. + +### Zentrale API Guards und Request-Validierung + +`runApiGuards(...)` bündelt folgende Checks konsistent: + +- IP-Blocklist / IP-Allowlist +- CSRF (browser-basiert, server-to-server aware) +- Authentifizierung (`payload.auth`) +- Rate-Limiting inkl. standardisierter 429-Antwort + +Die Body-Validierung läuft über `src/lib/validation/api-validation.ts` mit: + +- einheitlichen Fehlercodes (`required`, `invalid_type`, `invalid_value`, `invalid_json`) +- strukturierter Fehlerantwort (`VALIDATION_FAILED`) +- optionalem Security-Event (`request_validation_failed`) + +### Security Observability (Logs, Metriken, Alerts) + +Security-relevante Blockierungen werden zentral erfasst: + +- `cron_auth_rejected`, `cron_secret_missing` +- `cron_execution_locked`, `cron_duplicate_ignored` +- `pdf_ssrf_blocked` +- `rate_limit_blocked` +- `ip_access_denied` +- `csrf_blocked` +- `auth_blocked` +- `request_validation_failed` + +Metrik-/Alert-Konfiguration: + +```bash +SECURITY_METRICS_WINDOW_MS=300000 +SECURITY_ALERT_COOLDOWN_MS=900000 +SECURITY_ALERT_THRESHOLD_DEFAULT=25 +SECURITY_ALERT_THRESHOLD_CRON_AUTH_REJECTED=10 +SECURITY_ALERT_THRESHOLD_PDF_SSRF_BLOCKED=5 +SECURITY_ALERT_THRESHOLD_RATE_LIMIT_BLOCKED=50 +``` + +Bei Schwellwert-Verletzungen werden Alerts über den vorhandenen Alert-Service dispatcht. + +### Secret Lifecycle Monitoring + +Zusätzlich zur Startup-Validierung werden Secret-Risiken im Monitoring-Snapshot erfasst: + +- fehlende Pflicht-Secrets (`missing`) +- ablaufende Secrets (`expiringSoon`) +- abgelaufene Secrets (`expired`) +- überfällige Rotation (`rotationOverdue`) + +Relevante Variablen: + +```bash +SECRET_EXPIRY_WARNING_DAYS=14 +SECRET_ROTATION_MAX_DAYS=90 +PAYLOAD_SECRET_ROTATED_AT=2026-02-01T00:00:00Z +PAYLOAD_SECRET_EXPIRES_AT=2026-08-01T00:00:00Z +CRON_SECRET_ROTATED_AT=2026-02-01T00:00:00Z +CRON_SECRET_EXPIRES_AT=2026-08-01T00:00:00Z +``` + +Wenn `*_EXPIRES_AT` gesetzt und bereits abgelaufen ist, schlägt der Serverstart in Production fehl (fail-closed). + +Runbook: `docs/anleitungen/SECRET_ROTATION_RUNBOOK.md` + ### Data Masking **Automatisch maskierte Felder:** @@ -294,6 +369,10 @@ pnpm test:unit | `ip-allowlist.unit.spec.ts` | 35 | CIDR, Wildcards, Endpoints, TRUST_PROXY | | `data-masking.unit.spec.ts` | 41 | Felder, Patterns, Rekursion | | `security-api.int.spec.ts` | 33 | API-Integration | +| `cron-auth.unit.spec.ts` | neu | Cron-Auth, Idempotency, Lock | +| `pdf-url-validation.unit.spec.ts` | neu | SSRF-Validierung | +| `users-access.unit.spec.ts` | neu | Users-Update/Field Access Regression | +| `newsletter-unsubscribe.unit.spec.ts` | neu | Token-Rotation & Replay-Schutz | --- @@ -304,12 +383,15 @@ pnpm test:unit - [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy) - [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne) - [ ] **`CRON_SECRET`** setzen (Pflicht für Cron-Endpunkte in Production) +- [ ] **`SCHEDULER_MODE=external`** in Production setzen +- [ ] **`CRON_LOCK_TTL_MS`** und **`CRON_IDEMPOTENCY_TTL_MS`** prüfen - [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen - [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken - [ ] `GENERATE_PDF_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken - [ ] `ADMIN_ALLOWED_IPS` auf Office/VPN-IPs setzen - [ ] `PDF_ALLOWED_HOSTS` für erlaubte externe Render-Ziele konfigurieren -- [ ] `ENABLE_IN_PROCESS_SCHEDULER` in Multi-Instance-Deployments nur gezielt aktivieren +- [ ] `ENABLE_IN_PROCESS_SCHEDULER` nur für lokale Entwicklung aktivieren +- [ ] `SECRET_EXPIRY_WARNING_DAYS` / `SECRET_ROTATION_MAX_DAYS` passend setzen - [ ] Redis für verteiltes Rate Limiting konfigurieren - [ ] Pre-Commit Hook aktivieren @@ -319,6 +401,19 @@ pnpm test:unit - CSRF Validation Failures überwachen - Blocked IP Attempts tracken - Login-Fehlversuche (authLimiter) alertieren +- Cron-Auth-Rejections und Cron-Lock-Kollisionen alertieren +- SSRF-Blocks bei PDF-Generierung monitoren +- Secret-Lifecycle (`external.secrets`) täglich prüfen + +### Repo-History-Scan + +Für History-Scans steht `scripts/security/history-scan.sh` bereit: + +```bash +./scripts/security/history-scan.sh +``` + +Aktueller Befund: `docs/reports/2026-02-17-history-scan.md` --- diff --git a/src/app/(payload)/api/auth/login/route.ts b/src/app/(payload)/api/auth/login/route.ts index ce294dc..5a87b99 100644 --- a/src/app/(payload)/api/auth/login/route.ts +++ b/src/app/(payload)/api/auth/login/route.ts @@ -56,12 +56,14 @@ function validateLoginBody(input: unknown): ApiValidationResult { const emailResult = requiredString(objectResult.data, 'email') const passwordResult = requiredString(objectResult.data, 'password') - const issues = [ - ...(emailResult.valid ? [] : emailResult.issues), - ...(passwordResult.valid ? [] : passwordResult.issues), - ] - if (issues.length > 0) { - return { valid: false, issues } + if (!emailResult.valid || !passwordResult.valid) { + return { + valid: false, + issues: [ + ...(emailResult.valid ? [] : emailResult.issues), + ...(passwordResult.valid ? [] : passwordResult.issues), + ], + } } return { diff --git a/src/app/(payload)/api/generate-pdf/route.ts b/src/app/(payload)/api/generate-pdf/route.ts index 0f0561c..395cb20 100644 --- a/src/app/(payload)/api/generate-pdf/route.ts +++ b/src/app/(payload)/api/generate-pdf/route.ts @@ -45,7 +45,7 @@ interface GeneratePdfBody { url?: string options: Record queued: boolean - documentType?: string + documentType?: 'invoice' | 'report' | 'export' | 'certificate' | 'other' filename?: string priority?: 'high' | 'normal' | 'low' } @@ -68,6 +68,7 @@ function validateGeneratePdfBody(input: unknown): ApiValidationResult 0) { - return { valid: false, issues } + if (!tokenResult.valid || !emailResult.valid || !tenantResult.valid) { + return { + valid: false, + issues: [ + ...(tokenResult.valid ? [] : tokenResult.issues), + ...(emailResult.valid ? [] : emailResult.issues), + ...(tenantResult.valid ? [] : tenantResult.issues), + ], + } } if (!tokenResult.data && !(emailResult.data && tenantResult.data !== undefined)) { diff --git a/src/lib/security/api-guards.ts b/src/lib/security/api-guards.ts index 80967f9..ab4c4b0 100644 --- a/src/lib/security/api-guards.ts +++ b/src/lib/security/api-guards.ts @@ -65,11 +65,9 @@ export function createApiErrorResponse( ): NextResponse { return NextResponse.json( { - error: { - code, - message, - details, - }, + error: message, + code, + details, }, { status, diff --git a/tests/unit/security/cron-auth.unit.spec.ts b/tests/unit/security/cron-auth.unit.spec.ts index 7d808e6..f732271 100644 --- a/tests/unit/security/cron-auth.unit.spec.ts +++ b/tests/unit/security/cron-auth.unit.spec.ts @@ -85,7 +85,7 @@ describe('cron auth and coordination', () => { ) resetCronCoordinationStateForTests() - let releaseHandler: (() => void) | null = null + let releaseHandler: () => void = () => {} const firstRun = withCronExecution( createCronRequest('https://example.com/api/cron/test', 'Bearer top-secret-token'), { endpoint: 'locked-cron', lockTtlMs: 120000 }, @@ -103,9 +103,8 @@ describe('cron auth and coordination', () => { expect(second.status).toBe(423) - releaseHandler?.() + releaseHandler() const first = await firstRun expect(first.status).toBe(200) }) }) - diff --git a/tests/unit/security/pdf-url-validation.unit.spec.ts b/tests/unit/security/pdf-url-validation.unit.spec.ts index 9c7edab..161acca 100644 --- a/tests/unit/security/pdf-url-validation.unit.spec.ts +++ b/tests/unit/security/pdf-url-validation.unit.spec.ts @@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' vi.mock('dns/promises', () => ({ lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }]), + default: { + lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }]), + }, })) describe('PDF source URL validation', () => {