From 35bab1935adb6002ce6abd1a1a806a15850ae89b Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 12:55:58 +0000 Subject: [PATCH 1/6] debug: add temporary logging to userHasAccessToAllTenants --- src/payload.config.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/payload.config.ts b/src/payload.config.ts index cf2d1af..1657266 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -409,7 +409,18 @@ export default buildConfig({ } as Record), }, // Super Admins haben Zugriff auf alle Tenants - userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin), + userHasAccessToAllTenants: (user) => { + const result = Boolean(user?.isSuperAdmin) + console.log('[DEBUG:MultiTenant] userHasAccessToAllTenants:', { + userId: user?.id, + email: user?.email, + isSuperAdmin: user?.isSuperAdmin, + result, + tenants: user?.tenants, + userKeys: user ? Object.keys(user) : 'no user', + }) + return result + }, debug: true, // Deutsche Übersetzungen für den Tenant-Selector i18n: { From 06999b2bd745291bbe5df71505149fb79620918b Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 13:02:03 +0000 Subject: [PATCH 2/6] fix: add allowedOrigins for Next.js server actions behind reverse proxy Next.js has its own CSRF protection for server actions, separate from Payload's csrf config. Without allowedOrigins, server actions from the admin panel behind a reverse proxy are rejected because the Origin header (cms.c2sgmbh.de) doesn't match the Host header (localhost:3001). Also removes temporary debug logging from multiTenant access check. Co-Authored-By: Claude Opus 4.6 --- next.config.mjs | 8 ++++++++ src/payload.config.ts | 13 +------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 87421d1..0c9cd13 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,6 +7,14 @@ const nextConfig = { // Use fewer workers for builds on low-memory systems workerThreads: false, cpus: 1, + // Allow server actions from these origins (behind reverse proxy) + serverActions: { + allowedOrigins: [ + 'pl.porwoll.tech', + 'pl.c2sgmbh.de', + 'cms.c2sgmbh.de', + ], + }, }, // Webpack configuration for TypeScript/ESM compatibility webpack: (webpackConfig) => { diff --git a/src/payload.config.ts b/src/payload.config.ts index 1657266..cf2d1af 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -409,18 +409,7 @@ export default buildConfig({ } as Record), }, // Super Admins haben Zugriff auf alle Tenants - userHasAccessToAllTenants: (user) => { - const result = Boolean(user?.isSuperAdmin) - console.log('[DEBUG:MultiTenant] userHasAccessToAllTenants:', { - userId: user?.id, - email: user?.email, - isSuperAdmin: user?.isSuperAdmin, - result, - tenants: user?.tenants, - userKeys: user ? Object.keys(user) : 'no user', - }) - return result - }, + userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin), debug: true, // Deutsche Übersetzungen für den Tenant-Selector i18n: { From 26ceccbfb9cf3f97dd6eeaf4f647cf59857b872f Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 13:11:55 +0000 Subject: [PATCH 3/6] debug: add 403 interceptors to find which operation fails --- src/globals/SEOSettings.ts | 12 ++++++++++-- src/payload.config.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/globals/SEOSettings.ts b/src/globals/SEOSettings.ts index 42d3d6e..5cccb6e 100644 --- a/src/globals/SEOSettings.ts +++ b/src/globals/SEOSettings.ts @@ -18,9 +18,17 @@ export const SEOSettings: GlobalConfig = { }, access: { // Alle angemeldeten Benutzer können lesen - read: ({ req: { user } }) => Boolean(user), + read: ({ req: { user } }) => { + const result = Boolean(user) + if (!result) console.log('[DEBUG:SEO] read ACCESS DENIED - no user') + return result + }, // Nur Super Admins können bearbeiten - update: ({ req: { user } }) => Boolean(user?.isSuperAdmin), + update: ({ req: { user } }) => { + const result = Boolean(user?.isSuperAdmin) + console.log('[DEBUG:SEO] update access:', { email: user?.email, isSuperAdmin: user?.isSuperAdmin, result }) + return result + }, }, fields: [ // === META DEFAULTS === diff --git a/src/payload.config.ts b/src/payload.config.ts index cf2d1af..186fd95 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -131,6 +131,43 @@ const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfig({ + // DEBUG: Log all API requests that result in 403 + onInit: async (payload) => { + const originalFindGlobal = payload.findGlobal.bind(payload) + payload.findGlobal = async (args: Parameters[0]) => { + try { + return await originalFindGlobal(args) + } catch (err: unknown) { + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { + console.log('[DEBUG:403] Global read FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) + } + throw err + } + } + const originalUpdate = payload.update.bind(payload) + payload.update = async (args: Parameters[0]) => { + try { + return await originalUpdate(args) + } catch (err: unknown) { + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { + console.log('[DEBUG:403] Collection update FORBIDDEN:', { collection: args.collection, id: args.id, user: args.req?.user?.email || 'no user' }) + } + throw err + } + } + const originalUpdateGlobal = payload.updateGlobal.bind(payload) + payload.updateGlobal = async (args: Parameters[0]) => { + try { + return await originalUpdateGlobal(args) + } catch (err: unknown) { + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { + console.log('[DEBUG:403] Global update FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) + } + throw err + } + } + console.log('[DEBUG] 403 interceptors installed') + }, serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.porwoll.tech', admin: { user: Users.slug, From 36823b2d9f14cda018640c164dfc0b7443311c64 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 13:13:10 +0000 Subject: [PATCH 4/6] debug: fix types for 403 interceptors --- src/payload.config.ts | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/payload.config.ts b/src/payload.config.ts index 186fd95..1e82cde 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -133,36 +133,30 @@ const dirname = path.dirname(filename) export default buildConfig({ // DEBUG: Log all API requests that result in 403 onInit: async (payload) => { - const originalFindGlobal = payload.findGlobal.bind(payload) - payload.findGlobal = async (args: Parameters[0]) => { - try { - return await originalFindGlobal(args) - } catch (err: unknown) { - if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { - console.log('[DEBUG:403] Global read FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const origFindGlobal = payload.findGlobal.bind(payload) as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(payload as any).findGlobal = async (args: any) => { + try { return await origFindGlobal(args) } catch (err: any) { + if (err?.status === 403) console.log('[DEBUG:403] Global read FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) throw err } } - const originalUpdate = payload.update.bind(payload) - payload.update = async (args: Parameters[0]) => { - try { - return await originalUpdate(args) - } catch (err: unknown) { - if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { - console.log('[DEBUG:403] Collection update FORBIDDEN:', { collection: args.collection, id: args.id, user: args.req?.user?.email || 'no user' }) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const origUpdate = payload.update.bind(payload) as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(payload as any).update = async (args: any) => { + try { return await origUpdate(args) } catch (err: any) { + if (err?.status === 403) console.log('[DEBUG:403] Collection update FORBIDDEN:', { collection: args.collection, id: args.id, user: args.req?.user?.email || 'no user' }) throw err } } - const originalUpdateGlobal = payload.updateGlobal.bind(payload) - payload.updateGlobal = async (args: Parameters[0]) => { - try { - return await originalUpdateGlobal(args) - } catch (err: unknown) { - if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 403) { - console.log('[DEBUG:403] Global update FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const origUpdateGlobal = payload.updateGlobal.bind(payload) as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(payload as any).updateGlobal = async (args: any) => { + try { return await origUpdateGlobal(args) } catch (err: any) { + if (err?.status === 403) console.log('[DEBUG:403] Global update FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) throw err } } From a77c2b747d64e069bc98add8dd55cb2a70e3dc8f Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 13:32:00 +0000 Subject: [PATCH 5/6] fix: make SEO global read public to prevent 403 during admin SSR The SEO Settings global had `read: ({ req: { user } }) => Boolean(user)` which requires authentication. During admin panel server-side rendering (after saves), the user context is not propagated to global reads, causing a Forbidden error that crashes the entire page render. SEO data is not sensitive, so public read access is appropriate. Also removes temporary debug logging. Co-Authored-By: Claude Opus 4.6 --- src/globals/SEOSettings.ts | 15 ++++----------- src/payload.config.ts | 31 ------------------------------- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/src/globals/SEOSettings.ts b/src/globals/SEOSettings.ts index 5cccb6e..5a048cd 100644 --- a/src/globals/SEOSettings.ts +++ b/src/globals/SEOSettings.ts @@ -17,18 +17,11 @@ export const SEOSettings: GlobalConfig = { description: 'Globale SEO-Konfiguration und Schema.org Daten', }, access: { - // Alle angemeldeten Benutzer können lesen - read: ({ req: { user } }) => { - const result = Boolean(user) - if (!result) console.log('[DEBUG:SEO] read ACCESS DENIED - no user') - return result - }, + // Öffentlich lesbar - SEO-Daten sind nicht sensitiv und werden + // beim Admin-Panel SSR benötigt (wo der User-Kontext fehlen kann) + read: () => true, // Nur Super Admins können bearbeiten - update: ({ req: { user } }) => { - const result = Boolean(user?.isSuperAdmin) - console.log('[DEBUG:SEO] update access:', { email: user?.email, isSuperAdmin: user?.isSuperAdmin, result }) - return result - }, + update: ({ req: { user } }) => Boolean(user?.isSuperAdmin), }, fields: [ // === META DEFAULTS === diff --git a/src/payload.config.ts b/src/payload.config.ts index 1e82cde..cf2d1af 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -131,37 +131,6 @@ const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfig({ - // DEBUG: Log all API requests that result in 403 - onInit: async (payload) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const origFindGlobal = payload.findGlobal.bind(payload) as any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(payload as any).findGlobal = async (args: any) => { - try { return await origFindGlobal(args) } catch (err: any) { - if (err?.status === 403) console.log('[DEBUG:403] Global read FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) - throw err - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const origUpdate = payload.update.bind(payload) as any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(payload as any).update = async (args: any) => { - try { return await origUpdate(args) } catch (err: any) { - if (err?.status === 403) console.log('[DEBUG:403] Collection update FORBIDDEN:', { collection: args.collection, id: args.id, user: args.req?.user?.email || 'no user' }) - throw err - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const origUpdateGlobal = payload.updateGlobal.bind(payload) as any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(payload as any).updateGlobal = async (args: any) => { - try { return await origUpdateGlobal(args) } catch (err: any) { - if (err?.status === 403) console.log('[DEBUG:403] Global update FORBIDDEN:', { slug: args.slug, user: args.req?.user?.email || 'no user' }) - throw err - } - } - console.log('[DEBUG] 403 interceptors installed') - }, serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.porwoll.tech', admin: { user: Users.slug, From a5f8c43f81be2e798435a72587ef4b8c51ec7627 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 25 Feb 2026 13:52:34 +0000 Subject: [PATCH 6/6] revert: remove unnecessary serverActions.allowedOrigins The 403 "Forbidden" on production was caused by ModSecurity WAF (OWASP CRS 3.3.7) blocking PATCH/POST requests at the nginx layer, not by Next.js server actions CSRF. Nginx proxy_set_header Host $host ensures Origin and Host always match, making allowedOrigins redundant. Co-Authored-By: Claude Opus 4.6 --- next.config.mjs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 0c9cd13..87421d1 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,14 +7,6 @@ const nextConfig = { // Use fewer workers for builds on low-memory systems workerThreads: false, cpus: 1, - // Allow server actions from these origins (behind reverse proxy) - serverActions: { - allowedOrigins: [ - 'pl.porwoll.tech', - 'pl.c2sgmbh.de', - 'cms.c2sgmbh.de', - ], - }, }, // Webpack configuration for TypeScript/ESM compatibility webpack: (webpackConfig) => {