Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+4
View File
@@ -0,0 +1,4 @@
export class UserFacingError extends Error {}
export function publicActionError(error: unknown, fallback: string): string {
return error instanceof UserFacingError ? error.message : fallback;
}
+12
View File
@@ -0,0 +1,12 @@
import { NotdienstAlertStatus } from "@prisma/client";
import { hashToken } from "@/lib/tokens";
export function activeAlertTokenWhere(token: string) {
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
return {
confirmationToken: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] },
status: NotdienstAlertStatus.PENDING,
triggeredAt: { gt: new Date(Date.now() - 24 * 60 * 60_000) },
assignment: { date: { gte: today } },
parentUser: { disabledAt: null },
};
}
+37
View File
@@ -0,0 +1,37 @@
import { rm } from "node:fs/promises";
import path from "node:path";
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
export function attachmentDirectory(): string {
// Preserve the existing storage layout, including explicitly configured roots.
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
export function attachmentPath(id: string): string {
if (!/^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|c[a-z0-9]{24,32})$/i.test(id)) throw new Error("Invalid attachment ID");
return path.join(attachmentDirectory(), id);
}
export function detectAttachmentType(bytes: Buffer): string | null {
if (bytes.length < 12 || bytes.length > MAX_ATTACHMENT_SIZE) return null;
if (bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) &&
bytes.subarray(-12).equals(Buffer.from([0,0,0,0,73,69,78,68,174,66,96,130]))) return "image/png";
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes.subarray(-2).equals(Buffer.from([255,217]))) return "image/jpeg";
if (bytes.subarray(0, 5).toString("ascii") === "%PDF-" && bytes.subarray(-1024).toString("ascii").trimEnd().endsWith("%%EOF")) return "application/pdf";
return null;
}
export function attachmentExtensionMatches(name: string, type: string): boolean {
const extension = path.extname(name).toLowerCase();
return type === "image/jpeg" ? [".jpg", ".jpeg"].includes(extension) :
type === "image/png" ? extension === ".png" : type === "application/pdf" && extension === ".pdf";
}
export function attachmentDisposition(name: string): string {
const safe = name.replace(/[\r\n]/g, "").slice(0, 160);
const encoded = encodeURIComponent(safe).replace(/['()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
return `attachment; filename="${safe.replace(/[^\w.\-]/g, "_")}"; filename*=UTF-8''${encoded}`;
}
export async function removeAttachmentFiles(ids: string[]): Promise<void> {
await Promise.all(ids.map(async id => {
try { await rm(attachmentPath(id), { force: true }); }
catch (error) { console.error("Attachment cleanup failed; run the orphan cleanup command", id, error); }
}));
}
+17 -23
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { UserRole } from "@prisma/client";
@@ -28,12 +29,14 @@ export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string };
};
const readRequestSession = cache(() => auth());
export async function getSession(): Promise<Session | null> {
return auth();
return readRequestSession();
}
export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth();
const session = await getSession();
if (!session?.user?.id) {
redirect("/login");
}
@@ -46,17 +49,10 @@ export async function requireSession(): Promise<AuthenticatedSession> {
* für alle Tenant-gebundenen Server Actions / Page Components.
*/
export async function requireKitaSession(): Promise<KitaSession> {
if (process.env.TEST_MODE === "true") {
return {
user: {
id: process.env.TEST_USER_ID || "test-user-id",
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ELTERN,
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
},
} as any;
}
const session = await requireSession();
return enforceKitaSession(await requireSession());
}
async function enforceKitaSession(session: AuthenticatedSession): Promise<KitaSession> {
if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche.
@@ -80,20 +76,11 @@ export async function requireKitaSession(): Promise<KitaSession> {
export async function requireRole(
allowed: UserRole[],
): Promise<AuthenticatedSession> {
if (process.env.TEST_MODE === "true") {
return {
user: {
id: process.env.TEST_USER_ID || "test-user-id",
role: (process.env.TEST_USER_ROLE as UserRole) || UserRole.ADMIN,
kitaId: process.env.TEST_KITA_ID || "test-kita-id",
familyId: process.env.TEST_FAMILY_ID || "test-family-id",
},
} as any;
}
const session = await requireSession();
if (!allowed.includes(session.user.role)) {
redirect("/forbidden");
}
if (session.user.role !== UserRole.SUPERADMIN) return enforceKitaSession(session);
return session;
}
@@ -113,3 +100,10 @@ async function consentCheck(userId: string | undefined): Promise<boolean> {
});
return !!user?.privacyPolicyAcceptedAt;
}
// Route handlers use this non-redirecting counterpart of the same consent gate.
export async function getKitaSession(): Promise<KitaSession | null> {
const session = await getSession();
if (!session?.user?.id || !session.user.kitaId || !(await consentCheck(session.user.id))) return null;
return session as KitaSession;
}
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
export const dateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => {
const date = new Date(`${value}T00:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
});
export function validPlanMonth(year: unknown, month: unknown): boolean {
return typeof year === "number" && Number.isInteger(year) && year >= 2020 && year <= 2100 &&
typeof month === "number" && Number.isInteger(month) && month >= 1 && month <= 12;
}
+15 -9
View File
@@ -20,11 +20,11 @@ export function getAppEmailConfigError() {
return null;
}
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
return "E-Mail-Versand ist derzeit nicht verfügbar.";
}
if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert.";
return "E-Mail-Versand ist derzeit nicht verfügbar.";
}
return null;
@@ -37,6 +37,15 @@ export async function sendAppEmail({
from = process.env.EMAIL_FROM,
replyTo,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (Array.isArray(to)) {
let id: string | undefined;
for (const recipient of new Set(to)) {
const result = await sendAppEmail({ to: recipient, subject, react, from, replyTo });
if (!result.success) return result;
id = result.id;
}
return { success: true, id };
}
if (!process.env.RESEND_API_KEY) {
if (process.env.NODE_ENV === "development") {
console.log("\n✉️ [DEV EMAIL SIMULATION]");
@@ -50,14 +59,14 @@ export async function sendAppEmail({
}
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
};
}
if (!from) {
return {
success: false,
error: "EMAIL_FROM ist nicht konfiguriert.",
error: "E-Mail-Versand ist derzeit nicht verfügbar.",
};
}
@@ -75,7 +84,7 @@ export async function sendAppEmail({
console.error("Resend konnte die E-Mail nicht versenden:", error);
return {
success: false,
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
error: "E-Mail-Versand fehlgeschlagen.",
};
}
@@ -84,10 +93,7 @@ export async function sendAppEmail({
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
error: "E-Mail-Versand fehlgeschlagen.",
};
}
}
+5
View File
@@ -0,0 +1,5 @@
import { z } from "zod";
export const passwordSchema = z.string().min(8, "Mindestens 8 Zeichen.")
.refine(value => Buffer.byteLength(value, "utf8") <= 72,
"Das Passwort darf höchstens 72 UTF-8-Bytes umfassen.");
+3
View File
@@ -1,3 +1,6 @@
if (process.env.NODE_ENV === "production" && process.env.TEST_MODE === "true") {
throw new Error("TEST_MODE is forbidden in production");
}
import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
+33 -31
View File
@@ -1,39 +1,41 @@
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
import { createHash } from "node:crypto";
import { isIP } from "node:net";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
export type RateLimitResult =
| { allowed: true; remaining: number; resetAt: number }
| { allowed: false; retryAfterSeconds: number };
export function rateLimit(
key: string,
limit: number,
windowSeconds: number,
): RateLimitResult {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const existing = buckets.get(key);
// PostgreSQL performs the increment atomically across all application instances.
export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
const bucket = createHash("sha256").update(key).digest("hex");
const [row] = await prisma.$queryRaw<{ count: number; resetAt: Date }[]>`
INSERT INTO rate_limit_buckets (key, count, "resetAt")
VALUES (${bucket}, 1, NOW() + make_interval(secs => ${windowSeconds}))
ON CONFLICT (key) DO UPDATE SET
count = CASE WHEN rate_limit_buckets."resetAt" <= NOW() THEN 1
ELSE LEAST(rate_limit_buckets.count + 1, ${limit + 1}) END,
"resetAt" = CASE WHEN rate_limit_buckets."resetAt" <= NOW()
THEN NOW() + make_interval(secs => ${windowSeconds}) ELSE rate_limit_buckets."resetAt" END
RETURNING count, "resetAt"`;
// Indexed, bounded cleanup avoids an unbounded memory map or growing stale buckets.
await prisma.$executeRaw`DELETE FROM rate_limit_buckets WHERE key IN
(SELECT key FROM rate_limit_buckets WHERE "resetAt" < NOW() LIMIT 100)`;
return row.count <= limit
? { allowed: true, remaining: limit - row.count, resetAt: row.resetAt.getTime() }
: { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((row.resetAt.getTime() - Date.now()) / 1000)) };
}
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
export async function limitPublicAction(scope: string, identity: string, limit = 5, seconds = 3600): Promise<boolean> {
if (!(await rateLimit(`${scope}:burst`, 100, 60)).allowed) return false;
if (!(await rateLimit(`${scope}:global`, 1000, seconds)).allowed) return false;
if (!(await rateLimit(`${scope}:identity:${identity}`, limit, seconds)).allowed) return false;
// Enable only behind a reverse proxy that REPLACES this header. Never trust arbitrary forwarded lists.
const header = process.env.TRUSTED_CLIENT_IP_HEADER;
if (header && ["x-real-ip", "cf-connecting-ip"].includes(header)) {
const ip = (await headers()).get(header)?.trim();
if (ip && isIP(ip) && !(await rateLimit(`${scope}:ip:${ip}`, limit * 3, seconds)).allowed) return false;
}
if (existing.count >= limit) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000),
};
}
existing.count += 1;
return {
allowed: true,
remaining: limit - existing.count,
resetAt: existing.resetAt,
};
return true;
}
+7
View File
@@ -0,0 +1,7 @@
export function safeCallbackUrl(value: string | undefined): string {
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\x00-\x20]/.test(value)) return "/dashboard";
try {
const url = new URL(value, "https://kita.invalid");
return url.origin === "https://kita.invalid" ? `${url.pathname}${url.search}${url.hash}` : "/dashboard";
} catch { return "/dashboard"; }
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function newToken(): string { return randomBytes(32).toString("hex"); }
export function isPublicToken(token: unknown): token is string {
return typeof token === "string" && /^(?:[a-f0-9]{64}|[a-f0-9-]{36})$/i.test(token);
}
+11
View File
@@ -0,0 +1,11 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function serializableTransaction<T>(operation: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try { return await prisma.$transaction(operation, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }
catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2034" || attempt >= 2) throw error;
}
}
}