cms.c2sgmbh/docs/SECURITY_FIXES.md
Martin Porwoll a88e4f60d0 test: add E2E and integration tests with documentation
Tests:
- Update frontend.e2e.spec.ts with locale testing
- Add search.e2e.spec.ts for search functionality
- Add i18n.int.spec.ts for localization tests
- Add search.int.spec.ts for search integration
- Update playwright.config.ts

Documentation:
- Add CLAUDE.md with project instructions
- Add docs/ directory with detailed documentation
- Add scripts/ for utility scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 08:19:52 +00:00

21 KiB

Kontext Du arbeitest auf dem Server sv-payload im Verzeichnis /home/payload/payload-cms. Ein Sicherheits-Audit hat kritische Schwachstellen identifiziert, die sofort behoben werden müssen. Audit-Findings (KRITISCH) #SchwachstelleDateiRisiko1PAYLOAD_SECRET Fallback auf leeren Stringpayload.config.tsToken-Fälschung2CONSENT_LOGGING_API_KEY undefined = BypassConsentLogs.tsUnautorisierter Schreibzugriff3IP_ANONYMIZATION_PEPPER hardcoded FallbackConsentLogs.tsRainbow-Table Angriff4GraphQL Playground in Productiongraphql-playground/route.tsSchema-Leak5Multi-Tenant Read Access ohne Domain-CheckCookieConfigurations.ts, CookieInventory.tsTenant-Daten-Leak

Aufgabe 1: Zentrale Environment-Validierung erstellen Erstelle src/lib/envValidation.ts: typescript// src/lib/envValidation.ts

/**

  • Zentrale Validierung aller erforderlichen Environment-Variablen.
  • Wird beim Server-Start aufgerufen und beendet den Prozess bei fehlenden Werten. */

interface RequiredEnvVars { PAYLOAD_SECRET: string DATABASE_URI: string CONSENT_LOGGING_API_KEY: string IP_ANONYMIZATION_PEPPER: string }

const FORBIDDEN_VALUES = [ '', 'default-pepper-change-me', 'change-me', 'your-secret-here', 'xxx', ]

function validateEnvVar(name: string, value: string | undefined): string { if (!value || value.trim() === '') { throw new Error( FATAL: Environment variable ${name} is required but not set. + Server cannot start without this value. ) }

if (FORBIDDEN_VALUES.includes(value.trim().toLowerCase())) { throw new Error( FATAL: Environment variable ${name} has an insecure default value. + Please set a secure random value. ) }

return value.trim() }

/**

  • Validiert alle erforderlichen Environment-Variablen.
  • Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen. */ export function validateRequiredEnvVars(): RequiredEnvVars { return { PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET), DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI), CONSENT_LOGGING_API_KEY: validateEnvVar('CONSENT_LOGGING_API_KEY', process.env.CONSENT_LOGGING_API_KEY), IP_ANONYMIZATION_PEPPER: validateEnvVar('IP_ANONYMIZATION_PEPPER', process.env.IP_ANONYMIZATION_PEPPER), } }

/**

  • Bereits validierte Environment-Variablen.
  • Wird beim Import ausgeführt (Fail-Fast Prinzip). */ export const env = validateRequiredEnvVars()

Aufgabe 2: Tenant-Access Utility erstellen Erstelle src/lib/tenantAccess.ts: typescript// src/lib/tenantAccess.ts

import type { Access, PayloadRequest } from 'payload'

/**

  • Ermittelt die Tenant-ID aus dem Request-Host.

  • Gleicht die Domain mit der tenants-Collection ab. */ export async function getTenantIdFromHost(req: PayloadRequest): Promise<number | null> { try { // Host-Header extrahieren (unterstützt verschiedene Formate) const host = req.headers?.host || (req.headers?.get && req.headers.get('host')) || null

    if (!host || typeof host !== 'string') { return null }

    // Domain normalisieren: Port und www entfernen const domain = host .split(':')[0] .replace(/^www./, '') .toLowerCase() .trim()

    if (!domain) { return null }

    // Tenant aus Datenbank suchen const result = await req.payload.find({ collection: 'tenants', where: { domain: { equals: domain } }, limit: 1, depth: 0, })

    if (result.docs.length > 0 && result.docs[0]?.id) { return Number(result.docs[0].id) }

    return null } catch (error) { console.error('[TenantAccess] Error resolving tenant from host:', error) return null } }

/**

  • Access-Control für öffentlich lesbare, aber tenant-isolierte Collections.
    • Authentifizierte Admin-User: Voller Lesezugriff
    • Anonyme Requests: Nur Daten des eigenen Tenants (basierend auf Domain) */ export const tenantScopedPublicRead: Access = async ({ req }) => { // Authentifizierte Admins dürfen alles lesen if (req.user) { return true }

// Anonyme Requests: Tenant aus Domain ermitteln const tenantId = await getTenantIdFromHost(req)

if (!tenantId) { // Keine gültige Domain → kein Zugriff return false }

// Nur Dokumente des eigenen Tenants zurückgeben return { tenant: { equals: tenantId } } }

/**

  • Access-Control: Nur authentifizierte User */ export const authenticatedOnly: Access = ({ req }) => { return !!req.user }

Aufgabe 3: payload.config.ts aktualisieren Aktualisiere src/payload.config.ts: Am Anfang der Datei (nach den Imports) hinzufügen: typescript// Security: Validate required environment variables at startup import { env } from './lib/envValidation' Dann in buildConfig ändern: typescript// VORHER: secret: process.env.PAYLOAD_SECRET || '',

// NACHHER: secret: env.PAYLOAD_SECRET, Und: typescript// VORHER: db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI || '', }, }),

// NACHHER: db: postgresAdapter({ pool: { connectionString: env.DATABASE_URI, }, }),

Aufgabe 4: ConsentLogs.ts komplett ersetzen Ersetze src/collections/ConsentLogs.ts mit dieser sicheren Version: typescript// src/collections/ConsentLogs.ts

import type { CollectionConfig } from 'payload' import crypto from 'crypto' import { env } from '../lib/envValidation' import { authenticatedOnly } from '../lib/tenantAccess'

/**

  • Generiert einen täglichen, tenant-spezifischen Salt für IP-Anonymisierung.
  • Verwendet den sicher validierten Pepper aus der Umgebung. */ function getDailySalt(tenantId: string): string { const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD return crypto .createHash('sha256') .update(${env.IP_ANONYMIZATION_PEPPER}-${tenantId}-${date}) .digest('hex') }

/**

  • Anonymisiert eine IP-Adresse mit HMAC-SHA256.
  • Der Salt rotiert täglich und ist tenant-spezifisch. */ function anonymizeIp(ip: string, tenantId: string): string { const salt = getDailySalt(tenantId) return crypto .createHmac('sha256', salt) .update(ip) .digest('hex') .substring(0, 32) // Gekürzt für Lesbarkeit }

/**

  • Extrahiert die Client-IP aus dem Request.
  • Berücksichtigt Reverse-Proxy-Header. */ function extractClientIp(req: any): string { // X-Forwarded-For kann mehrere IPs enthalten (Client, Proxies) const forwarded = req.headers?.['x-forwarded-for'] if (typeof forwarded === 'string') { return forwarded.split(',')[0].trim() } if (Array.isArray(forwarded) && forwarded.length > 0) { return String(forwarded[0]).trim() }

// X-Real-IP (einzelne IP) const realIp = req.headers?.['x-real-ip'] if (typeof realIp === 'string') { return realIp.trim() }

// Fallback: Socket Remote Address return req.socket?.remoteAddress || req.ip || 'unknown' }

/**

  • ConsentLogs Collection - WORM Audit Trail
  • Implementiert das Write-Once-Read-Many Prinzip für DSGVO-Nachweispflicht.
  • Updates und Deletes sind auf API-Ebene deaktiviert. */ export const ConsentLogs: CollectionConfig = { slug: 'consent-logs', admin: { useAsTitle: 'consentId', group: 'Consent Management', description: 'WORM Audit-Trail für Cookie-Einwilligungen (unveränderbar)', defaultColumns: ['consentId', 'tenant', 'categories', 'revision', 'createdAt'], },

// Performance: Keine Versionierung für Audit-Logs versions: false,

access: { /** * CREATE: Nur mit gültigem API-Key. * Beide Seiten (Header UND Env-Variable) müssen existieren und übereinstimmen. */ create: ({ req }) => { const apiKey = req.headers?.['x-api-key']

  // Strikte Validierung: Header muss existieren und non-empty sein
  if (!apiKey || typeof apiKey !== 'string') {
    return false
  }
  
  const trimmedKey = apiKey.trim()
  if (trimmedKey === '') {
    return false
  }
  
  // Vergleich mit validiertem Environment-Wert
  // (env.CONSENT_LOGGING_API_KEY ist garantiert non-empty durch envValidation)
  return trimmedKey === env.CONSENT_LOGGING_API_KEY
},

/**
 * READ: Nur authentifizierte Admin-User
 */
read: authenticatedOnly,

/**
 * UPDATE: WORM - Niemals erlaubt
 */
update: () => false,

/**
 * DELETE: WORM - Niemals über API erlaubt
 * (Nur via Retention-Job mit direktem DB-Zugriff)
 */
delete: () => false,

},

hooks: { beforeChange: [ ({ data, req, operation }) => { // Nur bei Neuanlage if (operation !== 'create') { return data }

    // 1. Server-generierte Consent-ID (Trust Boundary)
    data.consentId = crypto.randomUUID()

    // 2. IP anonymisieren
    const rawIp = data.ip || extractClientIp(req)
    const tenantId = typeof data.tenant === 'object' 
      ? String(data.tenant.id) 
      : String(data.tenant)
    
    data.anonymizedIp = anonymizeIp(rawIp, tenantId)
    
    // Rohe IP NIEMALS speichern
    delete data.ip

    // 3. Ablaufdatum setzen (3 Jahre Retention gemäß DSGVO)
    const expiresAt = new Date()
    expiresAt.setFullYear(expiresAt.getFullYear() + 3)
    data.expiresAt = expiresAt.toISOString()

    // 4. User Agent kürzen (Datensparsamkeit)
    if (data.userAgent && typeof data.userAgent === 'string') {
      data.userAgent = data.userAgent.substring(0, 500)
    }

    return data
  },
],

},

fields: [ { name: 'consentId', type: 'text', required: true, unique: true, admin: { readOnly: true, description: 'Server-generierte eindeutige ID', }, }, { name: 'clientRef', type: 'text', admin: { readOnly: true, description: 'Client-seitige Referenz (Cookie-UUID) für Traceability', }, }, { name: 'tenant', type: 'relationship', relationTo: 'tenants', required: true, admin: { readOnly: true, }, }, { name: 'categories', type: 'json', required: true, admin: { readOnly: true, description: 'Akzeptierte Kategorien zum Zeitpunkt der Einwilligung', }, }, { name: 'revision', type: 'number', required: true, admin: { readOnly: true, description: 'Version der Konfiguration zum Zeitpunkt der Zustimmung', }, }, { name: 'userAgent', type: 'text', admin: { readOnly: true, description: 'Browser/Device (für Forensik und Bot-Erkennung)', }, }, { name: 'anonymizedIp', type: 'text', admin: { readOnly: true, description: 'HMAC-Hash der IP (täglich rotierender, tenant-spezifischer Salt)', }, }, { name: 'expiresAt', type: 'date', required: true, admin: { readOnly: true, description: 'Automatische Löschung nach 3 Jahren', date: { pickerAppearance: 'dayOnly', }, }, }, ], }

Aufgabe 5: CookieConfigurations.ts aktualisieren Ersetze src/collections/CookieConfigurations.ts: typescript// src/collections/CookieConfigurations.ts

import type { CollectionConfig } from 'payload' import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'

/**

  • CookieConfigurations Collection
  • Mandantenspezifische Cookie-Banner-Konfiguration.
  • Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert). */ export const CookieConfigurations: CollectionConfig = { slug: 'cookie-configurations', admin: { useAsTitle: 'title', group: 'Consent Management', description: 'Cookie-Banner Konfiguration pro Tenant', }, access: { // Öffentlich, aber tenant-isoliert (Domain-Check) read: tenantScopedPublicRead, create: authenticatedOnly, update: authenticatedOnly, delete: authenticatedOnly, }, fields: [ { name: 'tenant', type: 'relationship', relationTo: 'tenants', required: true, unique: true, admin: { description: 'Jeder Tenant kann nur eine Konfiguration haben', }, }, { name: 'title', type: 'text', required: true, defaultValue: 'Cookie-Einstellungen', admin: { description: 'Interner Titel zur Identifikation', }, }, { name: 'revision', type: 'number', required: true, defaultValue: 1, admin: { description: 'Bei inhaltlichen Änderungen erhöhen → erzwingt erneuten Consent bei allen Nutzern', }, }, { name: 'enabledCategories', type: 'select', hasMany: true, required: true, defaultValue: ['necessary', 'analytics'], options: [ { label: 'Notwendig', value: 'necessary' }, { label: 'Funktional', value: 'functional' }, { label: 'Statistik', value: 'analytics' }, { label: 'Marketing', value: 'marketing' }, ], admin: { description: 'Welche Kategorien sollen im Banner angezeigt werden?', }, }, { name: 'translations', type: 'group', fields: [ { name: 'de', type: 'group', label: 'Deutsch', fields: [ { name: 'bannerTitle', type: 'text', defaultValue: 'Wir respektieren Ihre Privatsphäre', }, { name: 'bannerDescription', type: 'textarea', defaultValue: 'Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten.', }, { name: 'acceptAllButton', type: 'text', defaultValue: 'Alle akzeptieren', }, { name: 'acceptNecessaryButton', type: 'text', defaultValue: 'Nur notwendige', }, { name: 'settingsButton', type: 'text', defaultValue: 'Einstellungen', }, { name: 'saveButton', type: 'text', defaultValue: 'Auswahl speichern', }, { name: 'privacyPolicyUrl', type: 'text', defaultValue: '/datenschutz', }, { name: 'categoryLabels', type: 'group', fields: [ { name: 'necessary', type: 'group', fields: [ { name: 'title', type: 'text', defaultValue: 'Notwendig' }, { name: 'description', type: 'textarea', defaultValue: 'Diese Cookies sind für die Grundfunktionen der Website erforderlich.' }, ], }, { name: 'functional', type: 'group', fields: [ { name: 'title', type: 'text', defaultValue: 'Funktional' }, { name: 'description', type: 'textarea', defaultValue: 'Diese Cookies ermöglichen erweiterte Funktionen.' }, ], }, { name: 'analytics', type: 'group', fields: [ { name: 'title', type: 'text', defaultValue: 'Statistik' }, { name: 'description', type: 'textarea', defaultValue: 'Diese Cookies helfen uns zu verstehen, wie Besucher die Website nutzen.' }, ], }, { name: 'marketing', type: 'group', fields: [ { name: 'title', type: 'text', defaultValue: 'Marketing' }, { name: 'description', type: 'textarea', defaultValue: 'Diese Cookies werden für Werbezwecke verwendet.' }, ], }, ], }, ], }, ], }, { name: 'styling', type: 'group', fields: [ { name: 'position', type: 'select', defaultValue: 'bottom', options: [ { label: 'Unten', value: 'bottom' }, { label: 'Oben', value: 'top' }, { label: 'Mitte (Modal)', value: 'middle' }, ], }, { name: 'theme', type: 'select', defaultValue: 'dark', options: [ { label: 'Dunkel', value: 'dark' }, { label: 'Hell', value: 'light' }, { label: 'Auto (System)', value: 'auto' }, ], }, ], }, ], }

Aufgabe 6: CookieInventory.ts aktualisieren Ersetze src/collections/CookieInventory.ts: typescript// src/collections/CookieInventory.ts

import type { CollectionConfig } from 'payload' import { tenantScopedPublicRead, authenticatedOnly } from '../lib/tenantAccess'

/**

  • CookieInventory Collection
  • Dokumentation aller verwendeten Cookies für die Datenschutzerklärung.
  • Öffentlich lesbar, aber nur für den eigenen Tenant (Domain-basiert). */ export const CookieInventory: CollectionConfig = { slug: 'cookie-inventory', admin: { useAsTitle: 'name', group: 'Consent Management', description: 'Cookie-Dokumentation für die Datenschutzerklärung', defaultColumns: ['name', 'provider', 'category', 'duration', 'tenant'], }, access: { // Öffentlich, aber tenant-isoliert (Domain-Check) read: tenantScopedPublicRead, create: authenticatedOnly, update: authenticatedOnly, delete: authenticatedOnly, }, fields: [ { name: 'tenant', type: 'relationship', relationTo: 'tenants', required: true, }, { name: 'name', type: 'text', required: true, admin: { description: 'Technischer Name des Cookies (z.B. "_ga")', }, }, { name: 'provider', type: 'text', required: true, admin: { description: 'Anbieter (z.B. "Google LLC")', }, }, { name: 'category', type: 'select', required: true, options: [ { label: 'Notwendig', value: 'necessary' }, { label: 'Funktional', value: 'functional' }, { label: 'Statistik', value: 'analytics' }, { label: 'Marketing', value: 'marketing' }, ], }, { name: 'duration', type: 'text', required: true, admin: { description: 'Speicherdauer (z.B. "2 Jahre")', }, }, { name: 'description', type: 'textarea', required: true, admin: { description: 'Verständliche Erklärung für Endnutzer', }, }, { name: 'isActive', type: 'checkbox', defaultValue: true, }, ], }

Aufgabe 7: GraphQL Playground entfernen Lösche die Datei: bashrm -f src/app/(payload)/api/graphql-playground/route.ts Falls das Verzeichnis danach leer ist: bashrmdir src/app/(payload)/api/graphql-playground/ 2>/dev/null || true

Aufgabe 8: Build und Test Nach allen Änderungen: bash# TypeScript kompilieren und prüfen pnpm build

Bei Erfolg: PM2 neu starten

pm2 restart payload

Logs prüfen (sollte ohne Fehler starten)

pm2 logs payload --lines 20 --nostream

Aufgabe 9: Sicherheitstest Teste die Fixes: bash# 1. Tenant-Isolation testen (sollte 403 oder leeres Array zurückgeben) curl -s "http://localhost:3000/api/cookie-configurations" | head -c 200

2. Mit korrektem Host-Header (sollte Daten für porwoll.de zeigen)

curl -s -H "Host: porwoll.de" "http://localhost:3000/api/cookie-configurations" | head -c 200

3. Consent-Log ohne API-Key (sollte 403 zurückgeben)

curl -X POST "http://localhost:3000/api/consent-logs"
-H "Content-Type: application/json"
-d '{"tenant":1,"categories":["necessary"],"revision":1}'

4. Consent-Log mit korrektem API-Key (sollte 201 zurückgeben)

curl -X POST "http://localhost:3000/api/consent-logs"
-H "Content-Type: application/json"
-H "x-api-key: $(grep CONSENT_LOGGING_API_KEY .env | cut -d= -f2)"
-d '{"tenant":1,"categories":["necessary"],"revision":1,"clientRef":"test-123"}'

5. GraphQL Playground (sollte 404 zurückgeben)

curl -s "http://localhost:3000/api/graphql-playground"

Zusammenfassung der Änderungen DateiAktionZwecksrc/lib/envValidation.tsNEUFail-Fast für fehlende Env-Varssrc/lib/tenantAccess.tsNEUDomain-basierte Tenant-Isolationsrc/payload.config.tsÄNDERNImport envValidation, sichere Secret-Verwendungsrc/collections/ConsentLogs.tsERSETZENStrikte API-Key-Prüfung, kein Pepper-Fallbacksrc/collections/CookieConfigurations.tsERSETZENTenant-scoped Read Accesssrc/collections/CookieInventory.tsERSETZENTenant-scoped Read Accesssrc/app/(payload)/api/graphql-playground/route.tsLÖSCHENKein Schema-Leak in Production Erwartetes Ergebnis

Server startet NUR wenn alle Env-Vars gesetzt sind Anonyme API-Requests sehen nur Daten ihres Tenants ConsentLogs nur mit validem API-Key beschreibbar GraphQL Playground nicht mehr erreichbar IP-Anonymisierung ohne unsichere Fallbacks