security: harden payload endpoints and access controls

This commit is contained in:
Martin Porwoll 2026-02-17 10:41:51 +00:00
parent 01a0a43f39
commit 063dae411c
31 changed files with 507 additions and 10210 deletions

View file

@ -21,6 +21,11 @@ REDIS_URL=redis://localhost:6379
# Security # Security
CSRF_SECRET=your-csrf-secret CSRF_SECRET=your-csrf-secret
TRUST_PROXY=true TRUST_PROXY=true
BLOCKED_IPS=
SEND_EMAIL_ALLOWED_IPS=
GENERATE_PDF_ALLOWED_IPS=
ADMIN_ALLOWED_IPS=
WEBHOOK_ALLOWED_IPS=
# YouTube OAuth (optional) # YouTube OAuth (optional)
GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_ID=your-client-id
@ -32,8 +37,17 @@ META_APP_ID=your-app-id
META_APP_SECRET=your-app-secret META_APP_SECRET=your-app-secret
META_REDIRECT_URI=http://localhost:3000/api/auth/meta/callback META_REDIRECT_URI=http://localhost:3000/api/auth/meta/callback
# Cron Jobs (optional) # Cron Jobs (required in production)
CRON_SECRET=your-64-char-hex CRON_SECRET=your-64-char-hex
# PDF Security
PDF_ALLOWED_HOSTS=example.com,.example.com
# Nur in non-production und nur falls zwingend notwendig aktivieren:
PDF_ALLOW_HTTP_URLS=false
# Scheduler
# In Production standardmäßig deaktiviert, um Doppel-Ausführungen in Multi-Instance-Deployments zu vermeiden
ENABLE_IN_PROCESS_SCHEDULER=false
# Tests # Tests
EMAIL_DELIVERY_DISABLED=false EMAIL_DELIVERY_DISABLED=false

9855
backup.sql

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# Security-Richtlinien - Payload CMS Multi-Tenant # Security-Richtlinien - Payload CMS Multi-Tenant
> Letzte Aktualisierung: 29.12.2025 > Letzte Aktualisierung: 17.02.2026
## Übersicht ## Übersicht
@ -25,6 +25,7 @@ Alle Security-Funktionen befinden sich in `src/lib/security/`:
| IP Allowlist | `ip-allowlist.ts` | IP-basierte Zugriffskontrolle | | IP Allowlist | `ip-allowlist.ts` | IP-basierte Zugriffskontrolle |
| CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz | | CSRF Protection | `csrf.ts` | Cross-Site Request Forgery Schutz |
| Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren | | Data Masking | `data-masking.ts` | Sensitive Daten in Logs maskieren |
| Cron Auth | `cron-auth.ts` | Verbindliche Authentifizierung für Cron-Endpunkte |
### Rate Limiter ### Rate Limiter
@ -86,6 +87,7 @@ export async function GET(req: NextRequest) {
| `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer | | `TRUST_PROXY` | Proxy-Header vertrauen | `true` oder leer |
| `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard | | `BLOCKED_IPS` | Globale Blocklist | IP, CIDR, Wildcard |
| `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard | | `SEND_EMAIL_ALLOWED_IPS` | E-Mail-Endpoint Allowlist | IP, CIDR, Wildcard |
| `GENERATE_PDF_ALLOWED_IPS` | PDF-Endpoint Allowlist | IP, CIDR, Wildcard |
| `ADMIN_ALLOWED_IPS` | Admin-Panel Allowlist | IP, CIDR, Wildcard | | `ADMIN_ALLOWED_IPS` | Admin-Panel Allowlist | IP, CIDR, Wildcard |
| `WEBHOOK_ALLOWED_IPS` | Webhook-Endpoint Allowlist | IP, CIDR, Wildcard | | `WEBHOOK_ALLOWED_IPS` | Webhook-Endpoint Allowlist | IP, CIDR, Wildcard |
@ -173,6 +175,32 @@ if (!csrf.valid) {
- Requests ohne `Origin`/`Referer` Header werden als Server-to-Server behandelt - Requests ohne `Origin`/`Referer` Header werden als Server-to-Server behandelt
- API-Key-basierte Authentifizierung umgeht CSRF-Check - API-Key-basierte Authentifizierung umgeht CSRF-Check
### Cron Endpoint Auth
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`
In Production wird `CRON_SECRET` beim Startup validiert.
### PDF URL Hardening (SSRF-Schutz)
PDF-Generierung per URL (`/api/generate-pdf`) validiert jetzt strikt:
- Nur `https://` URLs (Ausnahme: `PDF_ALLOW_HTTP_URLS=true` nur non-production)
- Keine URL-Credentials (`user:pass@host`)
- Keine localhost/private/loopback Ziele
- DNS-Auflösung wird geprüft (Rebinding-Schutz gegen private Zieladressen)
- Optionaler Host-Allowlist-Modus via `PDF_ALLOWED_HOSTS`
Beispiel:
```bash
PDF_ALLOWED_HOSTS=example.com,.example.com
PDF_ALLOW_HTTP_URLS=false
```
### Data Masking ### Data Masking
**Automatisch maskierte Felder:** **Automatisch maskierte Felder:**
@ -275,9 +303,13 @@ pnpm test:unit
- [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy) - [ ] **`TRUST_PROXY=true`** setzen (Pflicht hinter Reverse-Proxy wie Caddy)
- [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne) - [ ] **`CSRF_SECRET`** oder **`PAYLOAD_SECRET`** setzen (Server startet nicht ohne)
- [ ] **`CRON_SECRET`** setzen (Pflicht für Cron-Endpunkte in Production)
- [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen - [ ] Alle `BLOCKED_IPS` für bekannte Angreifer setzen
- [ ] `SEND_EMAIL_ALLOWED_IPS` auf vertrauenswürdige IPs beschränken - [ ] `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 - [ ] `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
- [ ] Redis für verteiltes Rate Limiting konfigurieren - [ ] Redis für verteiltes Rate Limiting konfigurieren
- [ ] Pre-Commit Hook aktivieren - [ ] Pre-Commit Hook aktivieren
@ -329,6 +361,7 @@ email=admin@example.com&password=secret
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 17.02.2026 | **Security-Hardening:** Users-Update fail-closed, `isSuperAdmin` field-protected, Cron-Auth fail-closed, PDF-SSRF-Schutz, Newsletter-Unsubscribe ohne ID-Enumeration |
| 29.12.2025 | **Dokumentation aktualisiert:** Custom Login Page Abschnitt entfernt (wurde am 27.12.2025 entfernt) | | 29.12.2025 | **Dokumentation aktualisiert:** Custom Login Page Abschnitt entfernt (wurde am 27.12.2025 entfernt) |
| 17.12.2025 | **Security-Audit Fixes:** TRUST_PROXY für IP-Header-Spoofing, CSRF_SECRET Pflicht in Production, IP-Allowlist Startup-Warnungen, Tests auf 177 erweitert | | 17.12.2025 | **Security-Audit Fixes:** TRUST_PROXY für IP-Header-Spoofing, CSRF_SECRET Pflicht in Production, IP-Allowlist Startup-Warnungen, Tests auf 177 erweitert |
| 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support | | 09.12.2025 | Custom Login Route Dokumentation, multipart/form-data _payload Support |

View file

@ -2,14 +2,6 @@ import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Skip type checking during build (done separately in CI)
typescript: {
ignoreBuildErrors: true,
},
// Skip ESLint during build to save memory
eslint: {
ignoreDuringBuilds: true,
},
// Reduce memory usage during build // Reduce memory usage during build
experimental: { experimental: {
// Use fewer workers for builds on low-memory systems // Use fewer workers for builds on low-memory systems

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays, differenceInHours } from 'date-fns' import { subDays, differenceInHours } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:ChannelComparison') const logger = createSafeLogger('API:ChannelComparison')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@ -41,7 +29,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays, differenceInHours } from 'date-fns' import { subDays, differenceInHours } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:AnalyticsOverview') const logger = createSafeLogger('API:AnalyticsOverview')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@ -42,7 +30,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays, differenceInHours } from 'date-fns' import { subDays, differenceInHours } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:ResponseMetrics') const logger = createSafeLogger('API:ResponseMetrics')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
function median(arr: number[]): number { function median(arr: number[]): number {
if (arr.length === 0) return 0 if (arr.length === 0) return 0
const sorted = [...arr].sort((a, b) => a - b) const sorted = [...arr].sort((a, b) => a - b)
@ -56,7 +44,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns' import { subDays, format, eachDayOfInterval, eachWeekOfInterval } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:SentimentTrend') const logger = createSafeLogger('API:SentimentTrend')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@ -48,7 +36,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays } from 'date-fns' import { subDays } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:TopContent') const logger = createSafeLogger('API:TopContent')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@ -44,7 +32,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,22 +12,10 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { subDays } from 'date-fns' import { subDays } from 'date-fns'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:TopicCloud') const logger = createSafeLogger('API:TopicCloud')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) return true
const communityRoles = ['community_manager', 'community_agent', 'admin']
return user.roles?.some((role) => communityRoles.includes(role)) ?? false
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@ -43,7 +31,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (!hasCommunityAccess(user as UserWithCommunityAccess)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -12,33 +12,11 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import ExcelJS from 'exceljs' import ExcelJS from 'exceljs'
import PDFDocument from 'pdfkit' import PDFDocument from 'pdfkit'
import { createSafeLogger, validateCsrf } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:CommunityExport') const logger = createSafeLogger('API:CommunityExport')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
/**
* Prüft ob User Community-Zugriff hat
*/
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) {
return true
}
const communityRoles = ['community_manager', 'community_agent', 'admin']
if (user.roles?.some((role) => communityRoles.includes(role))) {
return true
}
return false
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const searchParams = req.nextUrl.searchParams const searchParams = req.nextUrl.searchParams
@ -60,10 +38,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const typedUser = user as UserWithCommunityAccess const typedUser = user as { id: number }
// Community-Zugriff prüfen // Community-Zugriff prüfen
if (!hasCommunityAccess(typedUser)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
logger.warn('Access denied for export', { userId: typedUser.id }) logger.warn('Access denied for export', { userId: typedUser.id })
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -10,32 +10,10 @@ import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { createSafeLogger } from '@/lib/security' import { createSafeLogger } from '@/lib/security'
import { hasCommunityApiAccess } from '@/lib/communityAccess'
const logger = createSafeLogger('API:CommunityStats') const logger = createSafeLogger('API:CommunityStats')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
/**
* Prüft ob User Community-Zugriff hat
*/
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) {
return true
}
const communityRoles = ['community_manager', 'community_agent', 'admin']
if (user.roles?.some((role) => communityRoles.includes(role))) {
return true
}
return false
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@ -47,10 +25,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const typedUser = user as UserWithCommunityAccess const typedUser = user as { id: number }
// Community-Zugriff prüfen // Community-Zugriff prüfen
if (!hasCommunityAccess(typedUser)) { if (!hasCommunityApiAccess(user as any, 'viewer')) {
logger.warn('Access denied for stats', { userId: typedUser.id }) logger.warn('Access denied for stats', { userId: typedUser.id })
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }

View file

@ -8,9 +8,7 @@ import {
type SupportedPlatform, type SupportedPlatform,
type UnifiedSyncOptions, type UnifiedSyncOptions,
} from '@/lib/jobs/UnifiedSyncService' } from '@/lib/jobs/UnifiedSyncService'
import { requireCronAuth } from '@/lib/security'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
/** /**
* GET /api/cron/community-sync * GET /api/cron/community-sync
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
* - maxItems: Maximale Items pro Account (default: 100) * - maxItems: Maximale Items pro Account (default: 100)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to community-sync') console.warn('[Cron] Unauthorized request to community-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
// Query-Parameter parsen // Query-Parameter parsen
@ -118,14 +112,10 @@ export async function GET(request: NextRequest) {
* Manueller Sync-Trigger mit erweiterten Optionen * Manueller Sync-Trigger mit erweiterten Optionen
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Auth prüfen const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized POST to community-sync') console.warn('[Cron] Unauthorized POST to community-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
try { try {
@ -184,7 +174,12 @@ export async function POST(request: NextRequest) {
* HEAD /api/cron/community-sync * HEAD /api/cron/community-sync
* Status-Check für Monitoring * Status-Check für Monitoring
*/ */
export async function HEAD() { export async function HEAD(request: NextRequest) {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
const status = getUnifiedSyncStatus() const status = getUnifiedSyncStatus()
return new NextResponse(null, { return new NextResponse(null, {

View file

@ -5,9 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService' import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService'
import { requireCronAuth } from '@/lib/security'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
// Status für Monitoring // Status für Monitoring
let isRunning = false let isRunning = false
@ -21,14 +19,10 @@ let lastResult: { success: boolean; sent: number; failed: number } | null = null
* Läuft stündlich und prüft welche Reports gesendet werden müssen * Läuft stündlich und prüft welche Reports gesendet werden müssen
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to send-reports') console.warn('[Cron] Unauthorized request to send-reports')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
if (isRunning) { if (isRunning) {
@ -119,14 +113,10 @@ export async function GET(request: NextRequest) {
* Manuelles Senden eines bestimmten Reports * Manuelles Senden eines bestimmten Reports
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Auth prüfen const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized POST to send-reports') console.warn('[Cron] Unauthorized POST to send-reports')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
try { try {
@ -182,7 +172,12 @@ export async function POST(request: NextRequest) {
* HEAD /api/cron/send-reports * HEAD /api/cron/send-reports
* Status-Check für Monitoring * Status-Check für Monitoring
*/ */
export async function HEAD() { export async function HEAD(request: NextRequest) {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
return new NextResponse(null, { return new NextResponse(null, {
status: isRunning ? 423 : 200, status: isRunning ? 423 : 200,
headers: { headers: {

View file

@ -8,9 +8,7 @@ import {
type TokenRefreshOptions, type TokenRefreshOptions,
type TokenPlatform, type TokenPlatform,
} from '@/lib/jobs/TokenRefreshService' } from '@/lib/jobs/TokenRefreshService'
import { requireCronAuth } from '@/lib/security'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
/** /**
* GET /api/cron/token-refresh * GET /api/cron/token-refresh
@ -23,14 +21,10 @@ const CRON_SECRET = process.env.CRON_SECRET
* - dryRun: true/false - nur prüfen, nicht erneuern * - dryRun: true/false - nur prüfen, nicht erneuern
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to token-refresh') console.warn('[Cron] Unauthorized request to token-refresh')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
// Query-Parameter parsen // Query-Parameter parsen
@ -103,14 +97,10 @@ export async function GET(request: NextRequest) {
* Manueller Token-Refresh mit erweiterten Optionen * Manueller Token-Refresh mit erweiterten Optionen
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Auth prüfen const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized POST to token-refresh') console.warn('[Cron] Unauthorized POST to token-refresh')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
try { try {
@ -166,7 +156,12 @@ export async function POST(request: NextRequest) {
* HEAD /api/cron/token-refresh * HEAD /api/cron/token-refresh
* Status-Check für Monitoring * Status-Check für Monitoring
*/ */
export async function HEAD() { export async function HEAD(request: NextRequest) {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
const status = getTokenRefreshStatus() const status = getTokenRefreshStatus()
return new NextResponse(null, { return new NextResponse(null, {

View file

@ -6,8 +6,7 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService' import { ChannelMetricsSyncService } from '@/lib/integrations/youtube/ChannelMetricsSyncService'
import { requireCronAuth } from '@/lib/security'
const CRON_SECRET = process.env.CRON_SECRET
// Monitoring state // Monitoring state
let isRunning = false let isRunning = false
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
* Scheduled daily at 04:00 UTC via Vercel Cron. * Scheduled daily at 04:00 UTC via Vercel Cron.
*/ */
export async function GET(request: NextRequest): Promise<NextResponse> { export async function GET(request: NextRequest): Promise<NextResponse> {
if (CRON_SECRET) { const authError = requireCronAuth(request)
const authHeader = request.headers.get('authorization') if (authError) {
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to youtube-channel-sync') console.warn('[Cron] Unauthorized request to youtube-channel-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
if (isRunning) { if (isRunning) {
@ -73,7 +69,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
* HEAD /api/cron/youtube-channel-sync * HEAD /api/cron/youtube-channel-sync
* Status check for monitoring. * Status check for monitoring.
*/ */
export async function HEAD(): Promise<NextResponse> { export async function HEAD(request: NextRequest): Promise<NextResponse> {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
return new NextResponse(null, { return new NextResponse(null, {
status: isRunning ? 423 : 200, status: isRunning ? 423 : 200,
headers: { headers: {

View file

@ -6,8 +6,7 @@ import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService' import { VideoMetricsSyncService } from '@/lib/integrations/youtube/VideoMetricsSyncService'
import { requireCronAuth } from '@/lib/security'
const CRON_SECRET = process.env.CRON_SECRET
// Monitoring state // Monitoring state
let isRunning = false let isRunning = false
@ -19,13 +18,10 @@ let lastRunAt: Date | null = null
* Scheduled every 6 hours via Vercel Cron. * Scheduled every 6 hours via Vercel Cron.
*/ */
export async function GET(request: NextRequest): Promise<NextResponse> { export async function GET(request: NextRequest): Promise<NextResponse> {
if (CRON_SECRET) { const authError = requireCronAuth(request)
const authHeader = request.headers.get('authorization') if (authError) {
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to youtube-metrics-sync') console.warn('[Cron] Unauthorized request to youtube-metrics-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
if (isRunning) { if (isRunning) {
@ -102,7 +98,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
* HEAD /api/cron/youtube-metrics-sync * HEAD /api/cron/youtube-metrics-sync
* Status check for monitoring. * Status check for monitoring.
*/ */
export async function HEAD(): Promise<NextResponse> { export async function HEAD(request: NextRequest): Promise<NextResponse> {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
return new NextResponse(null, { return new NextResponse(null, {
status: isRunning ? 423 : 200, status: isRunning ? 423 : 200,
headers: { headers: {

View file

@ -2,23 +2,17 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments' import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
import { requireCronAuth } from '@/lib/security'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
/** /**
* GET /api/cron/youtube-sync * GET /api/cron/youtube-sync
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org) * Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt const authError = requireCronAuth(request)
if (CRON_SECRET) { if (authError) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to youtube-sync') console.warn('[Cron] Unauthorized request to youtube-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return authError
}
} }
console.log('[Cron] Starting scheduled YouTube sync') console.log('[Cron] Starting scheduled YouTube sync')
@ -49,7 +43,12 @@ export async function GET(request: NextRequest) {
* HEAD /api/cron/youtube-sync * HEAD /api/cron/youtube-sync
* Status-Check für Monitoring * Status-Check für Monitoring
*/ */
export async function HEAD() { export async function HEAD(request: NextRequest) {
const authError = requireCronAuth(request)
if (authError) {
return new NextResponse(null, { status: authError.status })
}
const status = getSyncStatus() const status = getSyncStatus()
return new NextResponse(null, { return new NextResponse(null, {

View file

@ -38,7 +38,7 @@ export async function GET(request: Request): Promise<Response> {
* Newsletter abbestellen (API-Version für AJAX) * Newsletter abbestellen (API-Version für AJAX)
* *
* Body: * Body:
* - token (required): Token oder Subscriber-ID * - token (required): Unsubscribe-Token
* - email (alternative): E-Mail-Adresse + tenantId * - email (alternative): E-Mail-Adresse + tenantId
* - tenantId (optional): Tenant-ID * - tenantId (optional): Tenant-ID
*/ */
@ -80,7 +80,25 @@ export async function POST(request: Request): Promise<Response> {
) )
} }
const result = await newsletterService.unsubscribe(String(subscriber.docs[0].id)) const foundSubscriber = subscriber.docs[0] as {
id: number
confirmationToken?: string | null
}
let actionToken = foundSubscriber.confirmationToken || null
if (!actionToken) {
actionToken = crypto.randomUUID()
await payload.update({
collection: 'newsletter-subscribers',
id: foundSubscriber.id,
data: {
confirmationToken: actionToken,
},
overrideAccess: true,
})
}
const result = await newsletterService.unsubscribe(actionToken)
return NextResponse.json(result, { return NextResponse.json(result, {
status: result.success ? 200 : 400, status: result.success ? 200 : 400,
}) })

View file

@ -9,8 +9,9 @@
* - IP-Blocklist-Prüfung * - IP-Blocklist-Prüfung
* - CSRF-Schutz für Browser-Requests * - CSRF-Schutz für Browser-Requests
* *
* Wichtig: Jedes Security-Feature ist in try-catch gewrappt, * Wichtig: Security-Checks laufen fail-closed.
* damit ein Fehler in einem Feature nicht den gesamten Login blockiert. * Wenn ein Sicherheitsmodul nicht verfügbar ist oder ein Check fehlschlägt,
* wird der Request abgelehnt.
*/ */
import { getPayload } from 'payload' import { getPayload } from 'payload'
@ -92,48 +93,54 @@ async function getAuditService(): Promise<AuditService> {
/** /**
* Extrahiert Client-IP aus Request * Extrahiert Client-IP aus Request
*/ */
function getClientIp(req: NextRequest): string { function getClientInfo(req: NextRequest, ipAddress: string): { ipAddress: string; userAgent: string } {
const forwarded = req.headers.get('x-forwarded-for')
const realIp = req.headers.get('x-real-ip')
return (forwarded ? forwarded.split(',')[0]?.trim() : undefined) || realIp || 'unknown'
}
/**
* Extrahiert Client-Informationen für Audit-Logging
*/
function getClientInfo(req: NextRequest): { ipAddress: string; userAgent: string } {
return { return {
ipAddress: getClientIp(req), ipAddress,
userAgent: req.headers.get('user-agent') || 'unknown', userAgent: req.headers.get('user-agent') || 'unknown',
} }
} }
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
const clientIp = getClientIp(req)
// 1. IP-Blocklist prüfen (optional, fail-safe)
try {
const security = await getSecurityModules() const security = await getSecurityModules()
if (security.isIpBlocked?.(clientIp)) { if (
!security.getClientIpFromRequest ||
!security.isIpBlocked ||
!security.validateCsrf ||
!security.authLimiter ||
!security.rateLimitHeaders
) {
console.error('[Login] Required security modules are unavailable')
return NextResponse.json(
{ errors: [{ message: 'Security checks unavailable' }] },
{ status: 503 },
)
}
const clientIp = security.getClientIpFromRequest(req)
// 1. IP-Blocklist prüfen
try {
if (security.isIpBlocked(clientIp)) {
return NextResponse.json( return NextResponse.json(
{ errors: [{ message: 'Access denied' }] }, { errors: [{ message: 'Access denied' }] },
{ status: 403 }, { status: 403 },
) )
} }
} catch (err) { } catch (err) {
console.warn('[Login] IP blocklist check failed:', err) console.error('[Login] IP blocklist check failed:', err)
// Continue - don't block login if check fails return NextResponse.json(
{ errors: [{ message: 'Security check failed' }] },
{ status: 503 },
)
} }
// 2. CSRF-Schutz (nur für Browser-Requests, fail-safe) // 2. CSRF-Schutz (nur für Browser-Requests)
// API-Requests ohne Origin-Header (CLI, Server-to-Server) brauchen kein CSRF // API-Requests ohne Origin-Header (CLI, Server-to-Server) brauchen kein CSRF
const origin = req.headers.get('origin') const origin = req.headers.get('origin')
const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json') const isApiRequest = !origin && req.headers.get('content-type')?.includes('application/json')
if (!isApiRequest) { if (!isApiRequest) {
try { try {
const security = await getSecurityModules()
if (security.validateCsrf) {
const csrfResult = security.validateCsrf(req) const csrfResult = security.validateCsrf(req)
if (!csrfResult.valid) { if (!csrfResult.valid) {
console.log('[Login] CSRF validation failed:', csrfResult.reason) console.log('[Login] CSRF validation failed:', csrfResult.reason)
@ -142,17 +149,17 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
{ status: 403 }, { status: 403 },
) )
} }
}
} catch (err) { } catch (err) {
console.warn('[Login] CSRF check failed:', err) console.error('[Login] CSRF check failed:', err)
// Continue - don't block login if check fails return NextResponse.json(
{ errors: [{ message: 'Security check failed' }] },
{ status: 503 },
)
} }
} }
// 3. Rate-Limiting prüfen (optional, fail-safe) // 3. Rate-Limiting prüfen
try { try {
const security = await getSecurityModules()
if (security.authLimiter) {
const rateLimit = await security.authLimiter.check(clientIp) const rateLimit = await security.authLimiter.check(clientIp)
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
// Optionally log rate limit hit // Optionally log rate limit hit
@ -164,16 +171,18 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
// Ignore audit logging errors // Ignore audit logging errors
} }
const headers = security.rateLimitHeaders?.(rateLimit, 5) || {} const headers = security.rateLimitHeaders(rateLimit, 5)
return NextResponse.json( return NextResponse.json(
{ errors: [{ message: 'Too many login attempts. Please try again later.' }] }, { errors: [{ message: 'Too many login attempts. Please try again later.' }] },
{ status: 429, headers }, { status: 429, headers },
) )
} }
}
} catch (err) { } catch (err) {
console.warn('[Login] Rate limiting check failed:', err) console.error('[Login] Rate limiting check failed:', err)
// Continue - don't block login if check fails return NextResponse.json(
{ errors: [{ message: 'Security check failed' }] },
{ status: 503 },
)
} }
// 4. Parse Request Body // 4. Parse Request Body
@ -278,7 +287,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
try { try {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const audit = await getAuditService() const audit = await getAuditService()
const clientInfo = getClientInfo(req) const clientInfo = getClientInfo(req, clientIp)
await audit.logLoginFailed?.(payload, email, reason, clientInfo) await audit.logLoginFailed?.(payload, email, reason, clientInfo)
console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`) console.log(`[Audit:Auth] Login failed for ${email}: ${reason} (IP: ${clientInfo.ipAddress})`)
} catch (auditErr) { } catch (auditErr) {

View file

@ -1,4 +1,4 @@
import type { CollectionConfig, Access } from 'payload' import type { CollectionConfig, Access, FieldAccess } from 'payload'
import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges' import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges'
import { import {
auditAfterLogin, auditAfterLogin,
@ -16,8 +16,12 @@ const canUpdateOwnAccount: Access = ({ req: { user }, id }) => {
if (user?.id && id && String(user.id) === String(id)) { if (user?.id && id && String(user.id) === String(id)) {
return true return true
} }
// Ansonsten Multi-Tenant Access Control // Ansonsten kein Zugriff
return true return false
}
const superAdminFieldAccess: FieldAccess = ({ req: { user } }) => {
return Boolean(user?.isSuperAdmin)
} }
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
@ -54,6 +58,11 @@ export const Users: CollectionConfig = {
type: 'checkbox', type: 'checkbox',
label: 'Super Admin', label: 'Super Admin',
defaultValue: false, defaultValue: false,
access: {
read: superAdminFieldAccess,
create: superAdminFieldAccess,
update: superAdminFieldAccess,
},
admin: { admin: {
description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.', description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.',
position: 'sidebar', position: 'sidebar',

View file

@ -9,15 +9,23 @@ export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') { if (process.env.NEXT_RUNTIME === 'nodejs') {
const { getPayload } = await import('payload') const { getPayload } = await import('payload')
const config = await import('./payload.config') const config = await import('./payload.config')
const { initScheduledJobs } = await import('./jobs/scheduler')
// Payload initialisieren // Payload initialisieren
const payload = await getPayload({ const payload = await getPayload({
config: config.default, config: config.default,
}) })
// Scheduled Jobs starten const enableInProcessScheduler =
process.env.ENABLE_IN_PROCESS_SCHEDULER === 'true' || process.env.NODE_ENV !== 'production'
if (enableInProcessScheduler) {
const { initScheduledJobs } = await import('./jobs/scheduler')
initScheduledJobs(payload) initScheduledJobs(payload)
} else {
console.log(
'[Instrumentation] In-process scheduler disabled (set ENABLE_IN_PROCESS_SCHEDULER=true to enable).',
)
}
console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.') console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.')
} }

View file

@ -10,6 +10,7 @@ interface UserWithRoles {
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager' youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
communityRole?: 'none' | 'viewer' | 'moderator' | 'manager' communityRole?: 'none' | 'viewer' | 'moderator' | 'manager'
community_role?: 'none' | 'viewer' | 'moderator' | 'manager' community_role?: 'none' | 'viewer' | 'moderator' | 'manager'
roles?: string[]
} }
const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => { const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => {
@ -27,16 +28,52 @@ const getYouTubeRole = (user: UserWithRoles | null): string | undefined => {
return user.youtubeRole || user.youtube_role return user.youtubeRole || user.youtube_role
} }
function getLegacyCommunityRole(user: UserWithRoles | null): 'none' | 'viewer' | 'moderator' | 'manager' {
if (!user?.roles?.length) return 'none'
if (user.roles.includes('admin') || user.roles.includes('community_manager')) return 'manager'
if (user.roles.includes('community_agent')) return 'moderator'
return 'none'
}
/**
* Einheitlicher Rollen-Check für Community APIs.
* Unterstützt camelCase/snake_case Felder und Legacy-Rollenlisten.
*/
export function hasCommunityApiAccess(
user: UserWithRoles | null,
minimumRole: 'viewer' | 'moderator' | 'manager' = 'viewer',
): boolean {
if (!user) return false
if (checkIsSuperAdmin(user)) return true
const roleOrder: Record<string, number> = {
none: 0,
viewer: 1,
moderator: 2,
manager: 3,
}
const communityRole = getCommunityRole(user) || getLegacyCommunityRole(user)
const ytRole = getYouTubeRole(user)
const ytCommunityLevel =
ytRole === 'manager'
? 'manager'
: ytRole === 'creator' || ytRole === 'producer' || ytRole === 'editor'
? 'moderator'
: ytRole === 'viewer'
? 'viewer'
: 'none'
const effectiveRole = roleOrder[communityRole] >= roleOrder[ytCommunityLevel] ? communityRole : ytCommunityLevel
return roleOrder[effectiveRole] >= roleOrder[minimumRole]
}
/** /**
* Prüft ob User Community-Manager oder Super-Admin ist * Prüft ob User Community-Manager oder Super-Admin ist
*/ */
export const isCommunityManager: Access = ({ req }) => { export const isCommunityManager: Access = ({ req }) => {
const user = req.user as UserWithRoles | null const user = req.user as UserWithRoles | null
if (!user) return false return hasCommunityApiAccess(user, 'manager')
if (checkIsSuperAdmin(user)) return true
// YouTube-Manager haben auch Community-Zugriff
if (getYouTubeRole(user) === 'manager') return true
return getCommunityRole(user) === 'manager'
} }
/** /**
@ -44,10 +81,7 @@ export const isCommunityManager: Access = ({ req }) => {
*/ */
export const isCommunityModeratorOrAbove: Access = ({ req }) => { export const isCommunityModeratorOrAbove: Access = ({ req }) => {
const user = req.user as UserWithRoles | null const user = req.user as UserWithRoles | null
if (!user) return false return hasCommunityApiAccess(user, 'moderator')
if (checkIsSuperAdmin(user)) return true
if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true
return ['moderator', 'manager'].includes(getCommunityRole(user) || '')
} }
/** /**
@ -55,12 +89,7 @@ export const isCommunityModeratorOrAbove: Access = ({ req }) => {
*/ */
export const hasCommunityAccess: Access = ({ req }) => { export const hasCommunityAccess: Access = ({ req }) => {
const user = req.user as UserWithRoles | null const user = req.user as UserWithRoles | null
if (!user) return false return hasCommunityApiAccess(user, 'viewer')
if (checkIsSuperAdmin(user)) return true
const ytRole = getYouTubeRole(user)
if (ytRole && ytRole !== 'none') return true
const commRole = getCommunityRole(user)
return commRole !== 'none' && commRole !== undefined
} }
/** /**

View file

@ -15,6 +15,7 @@ import {
// Token-Gültigkeitsdauer: 48 Stunden // Token-Gültigkeitsdauer: 48 Stunden
const TOKEN_EXPIRY_HOURS = 48 const TOKEN_EXPIRY_HOURS = 48
const UNSUBSCRIBE_TOKEN_EXPIRY_DAYS = 365
export interface SubscribeResult { export interface SubscribeResult {
success: boolean success: boolean
@ -232,14 +233,15 @@ export class NewsletterService {
} }
} }
// Status auf bestätigt setzen // Status auf bestätigt setzen und Token rotieren
const unsubscribeToken = crypto.randomUUID()
await this.payload.update({ await this.payload.update({
collection: 'newsletter-subscribers', collection: 'newsletter-subscribers',
id: subscriber.id, id: subscriber.id,
data: { data: {
status: 'confirmed', status: 'confirmed',
confirmedAt: new Date().toISOString(), confirmedAt: new Date().toISOString(),
confirmationToken: null, // Token löschen nach Verwendung confirmationToken: unsubscribeToken,
}, },
overrideAccess: true, overrideAccess: true,
}) })
@ -251,7 +253,10 @@ export class NewsletterService {
// Willkommens-E-Mail senden // Willkommens-E-Mail senden
if (tenantId) { if (tenantId) {
await this.sendWelcomeEmail(tenantId as number, subscriber) await this.sendWelcomeEmail(
tenantId as number,
{ ...subscriber, confirmationToken: unsubscribeToken } as NewsletterSubscriber,
)
} }
return { return {
@ -273,18 +278,13 @@ export class NewsletterService {
*/ */
async unsubscribe(token: string): Promise<UnsubscribeResult> { async unsubscribe(token: string): Promise<UnsubscribeResult> {
try { try {
// Subscriber mit Token oder ID finden // Subscriber ausschließlich per zufälligem Token finden
// Token kann entweder confirmationToken sein oder als ID interpretiert werden
let subscriber: NewsletterSubscriber | null = null let subscriber: NewsletterSubscriber | null = null
// Versuche zuerst nach Token zu suchen
const byToken = await this.payload.find({ const byToken = await this.payload.find({
collection: 'newsletter-subscribers', collection: 'newsletter-subscribers',
where: { where: {
or: [ confirmationToken: { equals: token },
{ confirmationToken: { equals: token } },
{ id: { equals: parseInt(token) || 0 } },
],
}, },
limit: 1, limit: 1,
depth: 1, depth: 1,
@ -309,6 +309,20 @@ export class NewsletterService {
} }
} }
if (subscriber.status === 'confirmed' && subscriber.confirmedAt) {
const confirmedAt = new Date(subscriber.confirmedAt)
const expiresAt = new Date(
confirmedAt.getTime() + UNSUBSCRIBE_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
)
if (new Date() > expiresAt) {
return {
success: false,
message: 'Der Abmelde-Link ist abgelaufen. Bitte fordern Sie einen neuen Link an.',
}
}
}
// Tenant-ID ermitteln // Tenant-ID ermitteln
const tenantId = typeof subscriber.tenant === 'object' && subscriber.tenant const tenantId = typeof subscriber.tenant === 'object' && subscriber.tenant
? subscriber.tenant.id ? subscriber.tenant.id
@ -321,6 +335,7 @@ export class NewsletterService {
data: { data: {
status: 'unsubscribed', status: 'unsubscribed',
unsubscribedAt: new Date().toISOString(), unsubscribedAt: new Date().toISOString(),
confirmationToken: crypto.randomUUID(),
}, },
overrideAccess: true, overrideAccess: true,
}) })
@ -365,17 +380,14 @@ export class NewsletterService {
} }
/** /**
* Willkommens-E-Mail nach erfolgreicher Bestätigung senden * Willkommens-E-Mail nach erfolgreicher Bestätigung senden.
* Verwendet immer die Subscriber-ID für Unsubscribe-Links,
* da der Token nach Bestätigung gelöscht wird.
*/ */
private async sendWelcomeEmail( private async sendWelcomeEmail(
tenantId: number, tenantId: number,
subscriber: NewsletterSubscriber, subscriber: NewsletterSubscriber,
): Promise<void> { ): Promise<void> {
const tenant = await this.getTenant(tenantId) const tenant = await this.getTenant(tenantId)
// Immer ID für Unsubscribe verwenden, da Token nach Bestätigung null ist const templateData = this.buildTemplateData(tenant, subscriber)
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
await sendTenantEmail(this.payload, tenantId, { await sendTenantEmail(this.payload, tenantId, {
to: subscriber.email, to: subscriber.email,
@ -391,16 +403,14 @@ export class NewsletterService {
} }
/** /**
* Abmelde-Bestätigung senden * Abmelde-Bestätigung senden.
* Verwendet die Subscriber-ID für Links, da kein Token mehr benötigt wird.
*/ */
private async sendUnsubscribeEmail( private async sendUnsubscribeEmail(
tenantId: number, tenantId: number,
subscriber: NewsletterSubscriber, subscriber: NewsletterSubscriber,
): Promise<void> { ): Promise<void> {
const tenant = await this.getTenant(tenantId) const tenant = await this.getTenant(tenantId)
// ID für Links verwenden const templateData = this.buildTemplateData(tenant, subscriber)
const templateData = this.buildTemplateData(tenant, subscriber, { useIdForUnsubscribe: true })
await sendTenantEmail(this.payload, tenantId, { await sendTenantEmail(this.payload, tenantId, {
to: subscriber.email, to: subscriber.email,
@ -430,14 +440,11 @@ export class NewsletterService {
/** /**
* Template-Daten zusammenstellen * Template-Daten zusammenstellen
* *
* Für Confirmation-E-Mails wird der Token verwendet. * Für Confirmation- und Unsubscribe-Links wird ein zufälliger Token verwendet.
* Für Willkommens- und andere E-Mails wird immer die ID verwendet,
* da der Token nach Bestätigung gelöscht wird.
*/ */
private buildTemplateData( private buildTemplateData(
tenant: Tenant, tenant: Tenant,
subscriber: NewsletterSubscriber, subscriber: NewsletterSubscriber,
options?: { useIdForUnsubscribe?: boolean },
): NewsletterTemplateData { ): NewsletterTemplateData {
// Tenant-Website URL ermitteln // Tenant-Website URL ermitteln
const tenantWebsite = tenant.domains?.[0]?.domain const tenantWebsite = tenant.domains?.[0]?.domain
@ -449,11 +456,7 @@ export class NewsletterService {
? `${tenantWebsite}/datenschutz` ? `${tenantWebsite}/datenschutz`
: undefined : undefined
// Für Unsubscribe immer ID verwenden wenn kein Token vorhanden const unsubscribeToken = subscriber.confirmationToken || ''
// oder wenn explizit angefordert (z.B. für Willkommens-E-Mail nach Bestätigung)
const unsubscribeToken = options?.useIdForUnsubscribe || !subscriber.confirmationToken
? String(subscriber.id)
: subscriber.confirmationToken
return { return {
firstName: subscriber.firstName || undefined, firstName: subscriber.firstName || undefined,

View file

@ -66,6 +66,11 @@ function validateEnvVar(name: string, value: string | undefined): string {
* Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen. * Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen.
*/ */
export function validateRequiredEnvVars(): RequiredEnvVars { export function validateRequiredEnvVars(): RequiredEnvVars {
// Production-only security requirements
if (process.env.NODE_ENV === 'production') {
validateEnvVar('CRON_SECRET', process.env.CRON_SECRET)
}
return { return {
PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET), PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET),
DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI), DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI),

View file

@ -8,14 +8,121 @@
import { chromium, Browser, Page } from 'playwright' import { chromium, Browser, Page } from 'playwright'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import { lookup } from 'dns/promises'
import { isIP } from 'net'
// Umgebungsvariablen // Umgebungsvariablen
const PDF_OUTPUT_DIR = process.env.PDF_OUTPUT_DIR || '/tmp/payload-pdfs' const PDF_OUTPUT_DIR = process.env.PDF_OUTPUT_DIR || '/tmp/payload-pdfs'
const PDF_GENERATION_DISABLED = process.env.PDF_GENERATION_DISABLED === 'true' const PDF_GENERATION_DISABLED = process.env.PDF_GENERATION_DISABLED === 'true'
const PDF_ALLOWED_HOSTS = (process.env.PDF_ALLOWED_HOSTS || '')
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean)
const PDF_ALLOW_HTTP_URLS =
process.env.NODE_ENV !== 'production' && process.env.PDF_ALLOW_HTTP_URLS === 'true'
// Browser-Instanz (wird wiederverwendet) // Browser-Instanz (wird wiederverwendet)
let browserInstance: Browser | null = null let browserInstance: Browser | null = null
function isPrivateIPv4(ip: string): boolean {
const octets = ip.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return true
}
const [a, b] = octets
if (a === 10) return true
if (a === 127) return true
if (a === 0) return true
if (a === 169 && b === 254) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
return false
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase()
if (normalized === '::1') return true
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true
if (normalized.startsWith('fe80')) return true
return false
}
function isPrivateOrLoopbackIp(ip: string): boolean {
const version = isIP(ip)
if (version === 4) return isPrivateIPv4(ip)
if (version === 6) return isPrivateIPv6(ip)
return true
}
function hostnameMatchesAllowedHost(hostname: string): boolean {
if (PDF_ALLOWED_HOSTS.length === 0) {
return true
}
return PDF_ALLOWED_HOSTS.some((allowedHost) => {
if (allowedHost.startsWith('.')) {
return hostname.endsWith(allowedHost)
}
return hostname === allowedHost
})
}
async function validatePdfSourceUrl(rawUrl: string): Promise<{ valid: boolean; reason?: string }> {
let parsedUrl: URL
try {
parsedUrl = new URL(rawUrl)
} catch {
return { valid: false, reason: 'Invalid URL format' }
}
const isHttps = parsedUrl.protocol === 'https:'
const isAllowedHttp = parsedUrl.protocol === 'http:' && PDF_ALLOW_HTTP_URLS
if (!isHttps && !isAllowedHttp) {
return { valid: false, reason: 'Only HTTPS URLs are allowed (or HTTP with PDF_ALLOW_HTTP_URLS=true in non-production)' }
}
if (parsedUrl.username || parsedUrl.password) {
return { valid: false, reason: 'URL credentials are not allowed' }
}
const hostname = parsedUrl.hostname.trim().toLowerCase()
if (!hostname) {
return { valid: false, reason: 'URL host is required' }
}
if (hostname === 'localhost' || hostname === '0.0.0.0') {
return { valid: false, reason: 'Localhost is not allowed' }
}
if (!hostnameMatchesAllowedHost(hostname)) {
return { valid: false, reason: 'Host is not in PDF_ALLOWED_HOSTS allowlist' }
}
if (isIP(hostname)) {
if (isPrivateOrLoopbackIp(hostname)) {
return { valid: false, reason: 'Private or loopback IP addresses are not allowed' }
}
return { valid: true }
}
try {
const records = await lookup(hostname, { all: true, verbatim: true })
if (!records.length) {
return { valid: false, reason: 'Host could not be resolved' }
}
if (records.some((record) => isPrivateOrLoopbackIp(record.address))) {
return { valid: false, reason: 'Host resolves to private or loopback addresses' }
}
} catch {
return { valid: false, reason: 'Host lookup failed' }
}
return { valid: true }
}
export interface PdfOptions { export interface PdfOptions {
format?: 'A4' | 'A3' | 'Letter' | 'Legal' format?: 'A4' | 'A3' | 'Letter' | 'Legal'
landscape?: boolean landscape?: boolean
@ -206,6 +313,15 @@ export async function generatePdfFromUrl(
let page: Page | null = null let page: Page | null = null
try { try {
const urlValidation = await validatePdfSourceUrl(url)
if (!urlValidation.valid) {
return {
success: false,
error: `URL validation failed: ${urlValidation.reason || 'Invalid URL'}`,
duration: Date.now() - startTime,
}
}
const browser = await getBrowser() const browser = await getBrowser()
page = await browser.newPage() page = await browser.newPage()

View file

@ -445,7 +445,7 @@ export async function searchPosts(
{ id: { in: ftsResult.postIds } }, { id: { in: ftsResult.postIds } },
] ]
if (categoryId) { if (categoryId) {
whereConditions.push({ category: { equals: categoryId } }) whereConditions.push({ categories: { contains: categoryId } })
} }
result = await payload.find({ result = await payload.find({
@ -486,7 +486,7 @@ export async function searchPosts(
} }
if (categoryId) { if (categoryId) {
whereConditions.push({ category: { equals: categoryId } }) whereConditions.push({ categories: { contains: categoryId } })
} }
const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0] const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0]
@ -584,7 +584,7 @@ export async function getSearchSuggestions(
limit: 1, limit: 1,
}) })
if (categoryResult.docs.length > 0) { if (categoryResult.docs.length > 0) {
whereConditions.push({ category: { equals: categoryResult.docs[0].id } }) whereConditions.push({ categories: { contains: categoryResult.docs[0].id } })
} }
} }
@ -647,7 +647,7 @@ export async function getPostsByCategory(
limit: 1, limit: 1,
}) })
if (categoryResult.docs.length > 0) { if (categoryResult.docs.length > 0) {
whereConditions.push({ category: { equals: categoryResult.docs[0].id } }) whereConditions.push({ categories: { contains: categoryResult.docs[0].id } })
} }
} }

View file

@ -0,0 +1,38 @@
import { timingSafeEqual } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
function safeTokenEquals(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
/**
* Enforces authorization for cron endpoints.
* Returns a response when auth fails, otherwise null.
*/
export function requireCronAuth(request: NextRequest): NextResponse | null {
const secret = process.env.CRON_SECRET?.trim()
// Fail closed when secret is missing.
if (!secret) {
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
}
const authorization = request.headers.get('authorization')
if (!authorization?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const token = authorization.slice('Bearer '.length).trim()
if (!token || !safeTokenEquals(token, secret)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return null
}

View file

@ -52,3 +52,6 @@ export {
createSafeLogger, createSafeLogger,
isSensitiveField, isSensitiveField,
} from './data-masking' } from './data-masking'
// Cron Endpoint Authentication
export { requireCronAuth } from './cron-auth'

View file

@ -129,6 +129,11 @@ const allowlistConfigs: Record<string, AllowlistConfig> = {
description: 'Admin Panel', description: 'Admin Panel',
allowAllIfEmpty: true, allowAllIfEmpty: true,
}, },
generatePdf: {
envVar: 'GENERATE_PDF_ALLOWED_IPS',
description: '/api/generate-pdf',
allowAllIfEmpty: true,
},
webhooks: { webhooks: {
envVar: 'WEBHOOK_ALLOWED_IPS', envVar: 'WEBHOOK_ALLOWED_IPS',
description: 'Webhook Endpoints', description: 'Webhook Endpoints',
@ -199,7 +204,7 @@ export function isIpAllowed(
const config = allowlistConfigs[endpoint] const config = allowlistConfigs[endpoint]
if (!config) { if (!config) {
return { allowed: true } // Unbekannter Endpoint = erlaubt return { allowed: false, reason: `Unknown allowlist endpoint: ${String(endpoint)}` }
} }
const allowedIps = parseIpList(process.env[config.envVar]) const allowedIps = parseIpList(process.env[config.envVar])

View file

@ -270,7 +270,7 @@ describe('Search API Integration', () => {
version: 1, version: 1,
}, },
}, },
...(testCategoryId ? { category: testCategoryId } : {}), ...(testCategoryId ? { categories: [testCategoryId] } : {}),
}, },
}) })
testPostId = post.id testPostId = post.id