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>
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