diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 90e903f..dbd820f 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,12 +1,14 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { createAdminClient } from "@/lib/supabase/admin"; +import { isSameOrigin } from "@/lib/origin"; // Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem // Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern // nicht über den anon key möglich ist. -async function requireAdmin() { +async function requireAdmin(request: Request) { + if (!isSameOrigin(request)) return null; const supabase = await createClient(); const { data: { user }, @@ -24,7 +26,7 @@ async function requireAdmin() { } export async function POST(request: Request) { - const ctx = await requireAdmin(); + const ctx = await requireAdmin(request); if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); const body = await request.json().catch(() => null); @@ -59,7 +61,7 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { - const ctx = await requireAdmin(); + const ctx = await requireAdmin(request); if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); const body = await request.json().catch(() => null); @@ -98,7 +100,7 @@ export async function PATCH(request: Request) { } export async function DELETE(request: Request) { - const ctx = await requireAdmin(); + const ctx = await requireAdmin(request); if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); const body = await request.json().catch(() => null); diff --git a/app/logout/route.ts b/app/logout/route.ts index ad221c5..b9aedc3 100644 --- a/app/logout/route.ts +++ b/app/logout/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { isSameOrigin } from "@/lib/origin"; export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } const supabase = await createClient(); await supabase.auth.signOut(); return NextResponse.redirect(new URL("/login", request.url), { status: 303 }); diff --git a/lib/origin.ts b/lib/origin.ts new file mode 100644 index 0000000..518150e --- /dev/null +++ b/lib/origin.ts @@ -0,0 +1,17 @@ +// CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler: +// Browser senden bei Cross-Site-POSTs immer einen Origin-Header – +// stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt. +// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session- +// Cookies von @supabase/ssr sowie der JSON-Content-Type.) +export function isSameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + // Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl); + // die Authentifizierung greift trotzdem. + if (!origin) return true; + const host = request.headers.get("host"); + try { + return new URL(origin).host === host; + } catch { + return false; + } +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..2973fa9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,28 @@ import type { NextConfig } from "next"; +// HTTP-Security-Header für alle Routen. +// HTTPS/HSTS übernimmt im Live-Betrieb der Reverse-Proxy. +const securityHeaders = [ + // Verhindert Einbettung in fremde Seiten (Clickjacking) + { key: "X-Frame-Options", value: "DENY" }, + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + // Browser darf Content-Types nicht "erraten" + { key: "X-Content-Type-Options", value: "nosniff" }, + // Keine vollständigen URLs an fremde Seiten weitergeben + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + // Sensorische APIs werden nicht benötigt + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, +]; + const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig;