From 8cb04fd130753b94526d14be619df46ccc8b80f4 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 27 Feb 2026 15:22:48 +0000 Subject: [PATCH] fix: enforce mandatory tenant parameter on frontend API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom API routes at /api/posts, /api/search, and /api/search/suggestions used payload.find() with overrideAccess:true (default) and optional tenant filtering. Without a ?tenant= parameter, ALL data from ALL tenants was returned — causing cross-tenant data leaks (e.g. sensualmoment.de Journal showing blogwoman articles). Now all three routes require a tenant parameter (400 error without it). Also accepts where[tenant][equals] format for compatibility with payload-contracts API clients. Removed debug logging from tenantAccess.ts. Co-Authored-By: Claude Opus 4.6 --- src/app/(frontend)/api/posts/route.ts | 27 ++++++++++++------- src/app/(frontend)/api/search/route.ts | 25 ++++++++++------- .../api/search/suggestions/route.ts | 25 ++++++++++------- src/lib/tenantAccess.ts | 23 ++++------------ 4 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/app/(frontend)/api/posts/route.ts b/src/app/(frontend)/api/posts/route.ts index c0d5476..9604885 100644 --- a/src/app/(frontend)/api/posts/route.ts +++ b/src/app/(frontend)/api/posts/route.ts @@ -46,11 +46,27 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const category = searchParams.get('category')?.trim() const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined - const tenantParam = searchParams.get('tenant') + const tenantParam = searchParams.get('tenant') || searchParams.get('where[tenant][equals]') const pageParam = searchParams.get('page') const limitParam = searchParams.get('limit') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -70,15 +86,6 @@ export async function GET(request: NextRequest) { Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), MAX_LIMIT ) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/app/(frontend)/api/search/route.ts b/src/app/(frontend)/api/search/route.ts index cc0dd5a..2f4e4e3 100644 --- a/src/app/(frontend)/api/search/route.ts +++ b/src/app/(frontend)/api/search/route.ts @@ -53,6 +53,22 @@ export async function GET(request: NextRequest) { const offsetParam = searchParams.get('offset') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -87,15 +103,6 @@ export async function GET(request: NextRequest) { MAX_LIMIT ) const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/app/(frontend)/api/search/suggestions/route.ts b/src/app/(frontend)/api/search/suggestions/route.ts index 06500cd..9966c69 100644 --- a/src/app/(frontend)/api/search/suggestions/route.ts +++ b/src/app/(frontend)/api/search/suggestions/route.ts @@ -51,6 +51,22 @@ export async function GET(request: NextRequest) { const limitParam = searchParams.get('limit') const localeParam = searchParams.get('locale')?.trim() + // Tenant is required for tenant isolation + if (!tenantParam) { + return NextResponse.json( + { error: 'Tenant ID is required. Use ?tenant= parameter.' }, + { status: 400 }, + ) + } + + const tenantId = parseInt(tenantParam, 10) + if (isNaN(tenantId) || tenantId < 1) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 }, + ) + } + // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' @@ -80,15 +96,6 @@ export async function GET(request: NextRequest) { Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), MAX_LIMIT ) - const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined - - // Validate tenant ID if provided - if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { - return NextResponse.json( - { error: 'Invalid tenant ID' }, - { status: 400 } - ) - } // Get payload instance const payload = await getPayload({ config }) diff --git a/src/lib/tenantAccess.ts b/src/lib/tenantAccess.ts index 24f2483..75d6364 100644 --- a/src/lib/tenantAccess.ts +++ b/src/lib/tenantAccess.ts @@ -80,25 +80,12 @@ function getTenantIdFromQuery(req: PayloadRequest): number | null { * method resolved the tenant ID. */ export const tenantScopedPublicRead: Access = async ({ req }) => { - // Authentifizierte Admins dürfen alles lesen - if (req.user) { - return true - } + const hasUser = !!req.user + const hostTenantId = await getTenantIdFromHost(req) + const queryTenantId = getTenantIdFromQuery(req) + const tenantId = hostTenantId ?? queryTenantId - // Anonyme Requests: Tenant aus Domain oder Query-Parameter ermitteln - const tenantId = (await getTenantIdFromHost(req)) ?? getTenantIdFromQuery(req) - - if (!tenantId) { - // Weder gültige Domain noch Tenant-Parameter → kein Zugriff - return false - } - - // Nur Dokumente des eigenen Tenants zurückgeben - return { - tenant: { - equals: tenantId, - }, - } + return hasUser ? true : tenantId ? { tenant: { equals: tenantId } } : false } /**