mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
fix: stabilize guard responses and validation typing
This commit is contained in:
parent
e3987e50dc
commit
bb02128b28
8 changed files with 186 additions and 26 deletions
57
docs/anleitungen/SECRET_ROTATION_RUNBOOK.md
Normal file
57
docs/anleitungen/SECRET_ROTATION_RUNBOOK.md
Normal file
|
|
@ -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:
|
||||
- `<SECRET>_ROTATED_AT=<ISO timestamp>`
|
||||
- optional `<SECRET>_EXPIRES_AT=<ISO timestamp>`
|
||||
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).
|
||||
|
|
@ -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 <CRON_SECRET>` 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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ function validateLoginBody(input: unknown): ApiValidationResult<LoginBody> {
|
|||
const emailResult = requiredString(objectResult.data, 'email')
|
||||
const passwordResult = requiredString(objectResult.data, 'password')
|
||||
|
||||
const issues = [
|
||||
if (!emailResult.valid || !passwordResult.valid) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [
|
||||
...(emailResult.valid ? [] : emailResult.issues),
|
||||
...(passwordResult.valid ? [] : passwordResult.issues),
|
||||
]
|
||||
if (issues.length > 0) {
|
||||
return { valid: false, issues }
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ interface GeneratePdfBody {
|
|||
url?: string
|
||||
options: Record<string, unknown>
|
||||
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<GeneratePd
|
|||
if (source !== 'html' && source !== 'url') {
|
||||
issues.push(validationIssue('source', 'invalid_value', 'source must be "html" or "url"'))
|
||||
}
|
||||
const sourceValue = source === 'html' || source === 'url' ? source : undefined
|
||||
|
||||
const html = typeof data.html === 'string' ? data.html : undefined
|
||||
const url = typeof data.url === 'string' ? data.url : undefined
|
||||
|
|
@ -83,7 +84,11 @@ function validateGeneratePdfBody(input: unknown): ApiValidationResult<GeneratePd
|
|||
: {}
|
||||
|
||||
const queued = typeof data.queued === 'boolean' ? data.queued : true
|
||||
const documentType = typeof data.documentType === 'string' ? data.documentType : undefined
|
||||
const documentType = ['invoice', 'report', 'export', 'certificate', 'other'].includes(
|
||||
String(data.documentType),
|
||||
)
|
||||
? (data.documentType as 'invoice' | 'report' | 'export' | 'certificate' | 'other')
|
||||
: undefined
|
||||
const filename = typeof data.filename === 'string' ? data.filename : undefined
|
||||
const priority = ['high', 'normal', 'low'].includes(String(data.priority))
|
||||
? (data.priority as 'high' | 'normal' | 'low')
|
||||
|
|
@ -100,7 +105,7 @@ function validateGeneratePdfBody(input: unknown): ApiValidationResult<GeneratePd
|
|||
valid: true,
|
||||
data: {
|
||||
tenantId: tenantIdValue,
|
||||
source,
|
||||
source: sourceValue as 'html' | 'url',
|
||||
html,
|
||||
url,
|
||||
options,
|
||||
|
|
|
|||
|
|
@ -31,14 +31,15 @@ function validateUnsubscribeBody(input: unknown): ApiValidationResult<Unsubscrib
|
|||
const emailResult = optionalString(objectResult.data, 'email')
|
||||
const tenantResult = optionalNumber(objectResult.data, 'tenantId')
|
||||
|
||||
const 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 (issues.length > 0) {
|
||||
return { valid: false, issues }
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenResult.data && !(emailResult.data && tenantResult.data !== undefined)) {
|
||||
|
|
|
|||
|
|
@ -65,12 +65,10 @@ export function createApiErrorResponse(
|
|||
): NextResponse {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
error: message,
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status,
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue