fix: enforce mandatory tenant parameter on frontend API routes

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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-27 15:22:48 +00:00
parent eb31df112b
commit 8cb04fd130
4 changed files with 54 additions and 46 deletions

View file

@ -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=<id> 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 })

View file

@ -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=<id> 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 })

View file

@ -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=<id> 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 })

View file

@ -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
}
/**