Harden authentication, tenant isolation and security boundaries
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export class UserFacingError extends Error {}
|
||||
export function publicActionError(error: unknown, fallback: string): string {
|
||||
return error instanceof UserFacingError ? error.message : fallback;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user