From 7cac8cd4fc3bf9dbd77a7ac3803462ed4971521c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 17 Jul 2026 18:21:22 +0200 Subject: [PATCH] =?UTF-8?q?Sicherheit:=20HTTP-Security-Header=20und=20Orig?= =?UTF-8?q?in-Pr=C3=BCfung=20f=C3=BCr=20schreibende=20Routen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-Frame-Options/CSP frame-ancestors gegen Clickjacking, nosniff, Referrer-Policy, Permissions-Policy - Origin-Check als CSRF-Defense-in-Depth für /api/admin/users und /logout (zusätzlich zu SameSite=Lax-Cookies und JSON-Content-Type) --- app/api/admin/users/route.ts | 10 ++++++---- app/logout/route.ts | 4 ++++ lib/origin.ts | 17 +++++++++++++++++ next.config.ts | 23 ++++++++++++++++++++++- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 lib/origin.ts 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;