From d2a1e7fc1e0bcb02242ccd41f4938cfd9562d9ea Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 22 Jul 2026 21:32:33 +0200 Subject: [PATCH] fix(security): Mandanten-Isolation + Rechte-Ausweitung geschlossen (v1.6.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus interner Sicherheits-Review (verifiziert): - Rapporte (GET+POST /api/rapports): Tenant-Check via getProjectWithTenantCheck ergänzt — vorher konnte jeder eingeloggte Nutzer mit fremder projectId Rapporte fremder Mandanten auflisten (inkl. Token → voller Inhalt) bzw. anlegen. POST blockt jetzt zusätzlich VIEWER. - Plan-Bild (GET .../plan-image/serve): globaler Lookup → getProjectWithTenantCheck. - Journal-Suggestions (GET /api/tenants/[tenantId]/suggestions): Membership-Check ergänzt (kein Cross-Tenant-Lesen mehr). - Admin-User-Anlegen: Nicht-SERVER_ADMIN kann nur noch im EIGENEN Tenant anlegen (mitgeschickte tenantId aus dem Body wird ignoriert) — verhindert Rechte- Ausweitung in fremde Organisationen. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/app/api/admin/users/route.ts | 6 +++++- .../projects/[id]/plan-image/serve/route.ts | 11 ++++------- src/app/api/rapports/route.ts | 18 +++++++++++++----- .../tenants/[tenantId]/suggestions/route.ts | 5 +++++ 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index b44614d..9bde3c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.6.2", + "version": "1.6.3", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index bbcaa74..2f8fc15 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -69,7 +69,11 @@ export async function POST(req: NextRequest) { } const hashedPassword = await hashPassword(data.password) - const tenantId = data.tenantId || session.tenantId + // Rechte-Ausweitung verhindern: Nicht-SERVER_ADMIN darf NUR im eigenen Tenant + // anlegen — der mitgeschickte tenantId aus dem Body wird ignoriert. + const tenantId = session.role === 'SERVER_ADMIN' + ? (data.tenantId || session.tenantId) + : session.tenantId const user = await (prisma as any).user.create({ data: { diff --git a/src/app/api/projects/[id]/plan-image/serve/route.ts b/src/app/api/projects/[id]/plan-image/serve/route.ts index b178342..08e6654 100644 --- a/src/app/api/projects/[id]/plan-image/serve/route.ts +++ b/src/app/api/projects/[id]/plan-image/serve/route.ts @@ -1,20 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/db' import { getSession } from '@/lib/auth' +import { getProjectWithTenantCheck } from '@/lib/tenant' import { getFileStream } from '@/lib/minio' -// Serve plan image (authenticated users only) +// Serve plan image (only for projects of the user's tenant) export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const user = await getSession() if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) - const project = await (prisma as any).project.findUnique({ - where: { id }, - select: { planImageKey: true }, - }) - + // Mandanten-Isolation statt globalem Lookup + const project = await getProjectWithTenantCheck(id, user) if (!project?.planImageKey) { return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 }) } diff --git a/src/app/api/rapports/route.ts b/src/app/api/rapports/route.ts index b142131..a4cffb9 100644 --- a/src/app/api/rapports/route.ts +++ b/src/app/api/rapports/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/db' import { getSession, isAdmin } from '@/lib/auth' +import { getProjectWithTenantCheck } from '@/lib/tenant' import QRCode from 'qrcode' // Helper: create a rapport record and return JSON response @@ -73,6 +74,12 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 }) } + // Mandanten-Isolation: nur Rapporte von Projekten des eigenen Tenants + const project = await getProjectWithTenantCheck(projectId, user) + if (!project) { + return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + } + const rapports = await (prisma as any).rapport.findMany({ where: { projectId }, orderBy: { createdAt: 'desc' }, @@ -98,6 +105,7 @@ export async function POST(req: NextRequest) { try { const user = await getSession() if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) let body: any try { @@ -113,11 +121,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 }) } - // Resolve tenantId: project → user session → membership lookup - const project = await (prisma as any).project.findUnique({ - where: { id: projectId }, - select: { tenantId: true }, - }) + // Mandanten-Isolation: Rapport nur für ein Projekt des eigenen Tenants anlegen + const project = await getProjectWithTenantCheck(projectId, user) + if (!project) { + return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + } let tenantId = project?.tenantId || user.tenantId || null if (!tenantId) { diff --git a/src/app/api/tenants/[tenantId]/suggestions/route.ts b/src/app/api/tenants/[tenantId]/suggestions/route.ts index d91cd1b..afb3c3b 100644 --- a/src/app/api/tenants/[tenantId]/suggestions/route.ts +++ b/src/app/api/tenants/[tenantId]/suggestions/route.ts @@ -12,6 +12,11 @@ export async function GET( const user = await getSession() if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + // Mandanten-Isolation: nur der eigene Tenant (SERVER_ADMIN darf alle) + if (user.role !== 'SERVER_ADMIN' && user.tenantId !== tenantId) { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + // Fetch from new Dictionary model (global + tenant) const [globalWords, tenantWords, tenant] = await Promise.all([ (prisma as any).dictionaryEntry.findMany({