Harden authentication, tenant isolation and security boundaries
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
@@ -13,15 +15,6 @@ import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = new Map([
|
||||
["application/pdf", ".pdf"],
|
||||
["image/jpeg", ".jpg"],
|
||||
["image/png", ".png"],
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
|
||||
|
||||
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
||||
|
||||
const announcementSchema = z.object({
|
||||
@@ -31,30 +24,10 @@ const announcementSchema = z.object({
|
||||
audience: audienceSchema,
|
||||
});
|
||||
|
||||
function getUploadDir() {
|
||||
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
|
||||
}
|
||||
|
||||
function getSafeFileName(name: string) {
|
||||
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
|
||||
}
|
||||
|
||||
function validateAttachment(file: File) {
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
const typeAllowed = ALLOWED_TYPES.has(file.type);
|
||||
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
|
||||
|
||||
if (!typeAllowed && !extensionAllowed) {
|
||||
return "Nur PDF, JPG und PNG sind erlaubt.";
|
||||
}
|
||||
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
return "Anhänge dürfen maximal 10 MB groß sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||
const where: Prisma.UserWhereInput =
|
||||
audience === "ELTERN"
|
||||
@@ -76,7 +49,7 @@ async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSc
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
@@ -106,56 +79,75 @@ export async function createAnnouncement(formData: FormData) {
|
||||
.getAll("attachments")
|
||||
.filter((value): value is File => value instanceof File && value.size > 0);
|
||||
|
||||
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) ||
|
||||
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) {
|
||||
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." };
|
||||
}
|
||||
if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) {
|
||||
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." };
|
||||
}
|
||||
const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = [];
|
||||
for (const file of attachmentFiles) {
|
||||
const error = validateAttachment(file);
|
||||
if (error) {
|
||||
return { error };
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const fileType = detectAttachmentType(bytes);
|
||||
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) {
|
||||
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." };
|
||||
}
|
||||
const id = randomUUID();
|
||||
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
|
||||
fileUrl: `/api/announcements/attachments/${id}` });
|
||||
}
|
||||
|
||||
const attachmentInputs = attachmentFiles.map((file) => {
|
||||
const id = randomUUID();
|
||||
return {
|
||||
id,
|
||||
file,
|
||||
fileName: getSafeFileName(file.name),
|
||||
fileType: file.type || "application/octet-stream",
|
||||
fileUrl: `/api/announcements/attachments/${id}`,
|
||||
};
|
||||
});
|
||||
|
||||
const announcement = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
const announcement = await serializableTransaction(async tx => {
|
||||
if (attachmentInputs.length) {
|
||||
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
||||
if (pending) return null;
|
||||
const [tenantUsage, globalUsage] = await Promise.all([
|
||||
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
|
||||
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
||||
]);
|
||||
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
|
||||
const quota = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
||||
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
|
||||
}
|
||||
return tx.announcement.create({
|
||||
data: {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileType: attachment.fileType,
|
||||
sizeBytes: attachment.bytes.length,
|
||||
fileUrl: attachment.fileUrl,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." };
|
||||
|
||||
if (attachmentInputs.length > 0) {
|
||||
const uploadDir = getUploadDir();
|
||||
const uploadDir = attachmentDirectory();
|
||||
try {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await Promise.all(
|
||||
attachmentInputs.map(async (attachment) => {
|
||||
const arrayBuffer = await attachment.file.arrayBuffer();
|
||||
await writeFile(
|
||||
path.join(uploadDir, attachment.id),
|
||||
Buffer.from(arrayBuffer),
|
||||
attachmentPath(attachment.id),
|
||||
attachment.bytes,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -163,7 +155,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
await prisma.announcement.delete({ where: { id: announcement.id } });
|
||||
await Promise.all(
|
||||
attachmentInputs.map((attachment) =>
|
||||
rm(path.join(uploadDir, attachment.id), { force: true }),
|
||||
rm(attachmentPath(attachment.id), { force: true }),
|
||||
),
|
||||
);
|
||||
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
|
||||
@@ -232,6 +224,7 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
kitaId: session.user.kitaId,
|
||||
userId: session.user.id,
|
||||
announcementId: announcement.id,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user