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

733 lines
No EOL
21 KiB
Markdown

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