tva
← Insights

Ein mehrstufiges Sicherheits-Audit: Eine Next.js-Anwendung absichern

Sicherheits-Audits von Webanwendungen finden unabhängig vom Stack meist dieselben Problemklassen: Autorisierungslücken, fehlende Eingabevalidierung, unzureichende HTTP-Sicherheitsheader und Timing-Schwachstellen in Authentifizierungsabläufen. Die konkrete Ausprägung unterscheidet sich je nach Framework, die zugrunde liegenden Muster bleiben jedoch gleich. Entscheidend ist, wie systematisch das Audit aufgebaut ist: Eine unstrukturierte Prüfung erfasst wahrscheinlich die offensichtlichen Probleme und übersieht die subtilen.

Wir haben eine produktive Next.js-Anwendung mit Supabase als Backend in mehreren Phasen geprüft. Die Anwendung authentifizierte Nutzer, speicherte nutzerbezogene Datensätze und stellte mehrere API-Routen bereit, die sowohl das Frontend als auch externe Webhooks nutzten. Keiner der Befunde war katastrophal. Mehrere gehörten jedoch zu den Problemen, aus denen ein schwerwiegender Vorfall werden kann, wenn jemand mit schädlichen Absichten sie entdeckt. Hier beschreiben wir, was das Audit umfasste, was es fand und was sich dadurch änderte.

Phase eins: Authentifizierung und Autorisierung

Die erste Phase konzentrierte sich auf die Authentifizierungsschicht und die Row-Level-Security-Richtlinien für den Datenzugriff. Das sind die wichtigsten Schutzmaßnahmen jeder Supabase-Anwendung: Ist die Authentifizierung fehlerhaft oder RLS falsch konfiguriert, lässt sich das weder durch Eingabevalidierung noch durch Aufruflimits ausgleichen.

Das Audit begann mit einer vollständigen Bestandsaufnahme aller Supabase-Tabellen und ihres RLS-Status. RLS sollte für jede Tabelle in einem exponierten Schema aktiviert sein. Mit den standardmäßigen Berechtigungen von Supabase kann jeder mit dem anon-Schlüssel Tabellen in einem exponierten Schema wie public ohne aktiviertes RLS lesen und beschreiben; dieser Schlüssel ist bewusst öffentlich und im clientseitigen Bundle enthalten. In dieser Anwendung war RLS bei einer Tabelle deaktiviert. Sie diente als Nachschlagetabelle für Referenzdaten und enthielt bei ihrer Erstellung nur statische Werte. Mit der Zeit kamen Zeilen mit nutzerbezogenen Metadaten hinzu, die hätten geschützt werden müssen. Die Korrektur war einfach: RLS aktivieren und eine Richtlinie hinzufügen. Der zugrunde liegende Prozessfehler war jedoch bedeutsam: Neue Tabellen sollten standardmäßig RLS verwenden, nicht erst nachträglich.

Anschließend wurden die RLS-Richtlinien der geschützten Tabellen auf Korrektheit geprüft. Ein häufiger Fehler ist eine Richtlinie, die die Nutzer-ID zwar korrekt prüft, aber in der falschen Spalte. Eine Richtlinie auf einer documents-Tabelle mit der Prüfung auth.uid() = created_by beschränkt den Zugriff beispielsweise korrekt auf den Ersteller. Unterstützt die Anwendung auch gemeinsam genutzte Dokumente, benötigt ein Eintrag für einen Mitwirkenden in der Verknüpfungstabelle document_collaborators jedoch eine eigene Richtlinie. Die Prüfung der Haupttabelle überträgt sich nicht auf verknüpfte Daten.

Eine Richtlinie enthielt einen Logikfehler, der die Codeprüfung überstanden hatte: Sie verwendete USING, obwohl sowohl USING als auch WITH CHECK nötig gewesen wären. Die USING-Klausel bestimmt, welche vorhandenen Zeilen ein Befehl sehen, aktualisieren oder löschen kann; WITH CHECK bestimmt, welche neuen oder geänderten Zeilen geschrieben werden dürfen. Fehlt in einer Richtlinie für Schreibzugriffe (FOR ALL oder FOR UPDATE) die Klausel WITH CHECK, verwendet PostgreSQL den USING-Ausdruck auch für Schreibzugriffe. Dadurch erlaubte eine als Leseregel formulierte USING-Klausel jedem authentifizierten Nutzer, Zeilen zu schreiben, die er lesen konnte. Das entsprach nicht dem beabsichtigten Verhalten. Diese Feinheit von PostgreSQL-RLS wird leicht übersehen.

Phase zwei: Eingabevalidierung

Die zweite Phase prüfte jede Stelle, an der Nutzereingaben ins System gelangen: API-Routenhandler in app/api/, Server Actions und Formulareingaben. Ziel war zu bestätigen, dass alle Eingaben vor ihrer Verwendung validiert werden und Validierungsfehler aussagekräftige Fehlermeldungen statt unbehandelter Ausnahmen erzeugen.

Die Anwendung verwendete teilweise Zod zur Validierung, allerdings uneinheitlich. Manche Routen hatten vollständige Schemata mit schema.parse() am Anfang des Handlers. Andere lasen Felder aus req.body direkt ohne Validierung und verließen sich auf TypeScript-Typzusicherungen, die zur Laufzeit keine Sicherheit bieten:

// Unsafe: TypeScript type assertion, no runtime validation
const { userId, documentId } = req.body as { userId: string; documentId: string };

// Safe: Zod parse, throws on invalid input
const schema = z.object({
  userId: z.string().uuid(),
  documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);

Zur Behebung wurden alle bislang ungeprüften API-Routen um Zod-Schemata ergänzt. Zusätzlich wurde eine gemeinsame Validierungs-Middleware eingerichtet, die alle Routen durchlaufen. Dieses Muster ist wichtig: Es verhindert, dass ein neu hinzugefügter Codepfad die Validierung umgeht, weil die Validierungsanforderung bei seiner Erstellung nicht bekannt war.

Eine Route akzeptierte einen Parameter redirectUrl, der in einem Aufruf von res.redirect() verwendet wurde. Das ist eine Schwachstelle für offene Weiterleitungen: Ein Angreifer kann einen Link zur Anwendung erstellen, der Nutzer nach einer legitim wirkenden Interaktion auf eine Phishing-Website umleitet. Die Korrektur besteht darin, das Weiterleitungsziel gegen eine Liste zulässiger Pfade zu prüfen:

const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];

const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
  return res.redirect('/dashboard');
}
return res.redirect(redirectPath);

Phase drei: HTTP-Sicherheitsheader und CSP

In der Anwendung fehlten Header für die Content Security Policy vollständig. Ohne CSP kann eine erfolgreiche XSS-Injektion – durch nutzergenerierte Inhalte oder ein kompromittiertes Drittanbieter-Skript – beliebiges JavaScript im Kontext der Anwendung ausführen und auf Authentifizierungstoken und Nutzerdaten zugreifen.

Das Hinzufügen einer CSP zu einer Next.js-Anwendung erfordert Sorgfalt, weil Next.js standardmäßig einige Skripte direkt in das HTML einbettet. Der empfohlene Ansatz verwendet Nonces: pro Anfrage erzeugte kryptografisch zufällige Werte, die im CSP-Header und bei jedem eingebetteten Skript hinterlegt werden. Next.js wendet die Nonce auf seine eigenen Skripte an, indem es die Richtlinie aus den Anfrage-Headern liest. Deshalb muss Middleware im Produktivbetrieb die CSP (und üblicherweise einen x-nonce-Header) auch über NextResponse.next({ request: { headers } }) weiterreichen, wie die CSP-Anleitung von Next.js zeigt; der folgende Ausschnitt setzt nur die Antwort-Header. Außerdem importiert er das Node-Modul crypto, das in der Edge-Laufzeit nicht verfügbar ist. Dort sollte die globale Web Crypto API verwendet werden (zum Beispiel crypto.randomUUID()):

// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request: Request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
}

Beim ersten CSP-Deployment wurde der Modus Content-Security-Policy-Report-Only verwendet, der Verstöße meldet, ohne sie zu blockieren. Dadurch wurden mehrere legitime eingebettete Skripte und Drittanbieter-Ursprünge sichtbar, die vor dem Wechsel in den erzwingenden Modus in die Liste zulässiger Quellen aufgenommen werden mussten. Eine Woche im reinen Meldemodus vor der Erzwingung ist ein gängiges Vorgehen. Ohne diesen Schritt werden typischerweise Funktionen blockiert, die in Tests nicht aufgefallen waren.

Das Audit zeigte auch, dass der Load Balancer den Header Strict-Transport-Security setzte, die Anwendung selbst jedoch nicht. Das ist akzeptabel, wenn der Load Balancer der einzige Zugangspunkt ist, sollte aber ausdrücklich bestätigt werden. Eine mehrschichtige Absicherung bedeutet, dass die Anwendung ihre eigenen Sicherheitsheader setzen sollte, selbst wenn die Infrastruktur sie bereits bereitstellt.

Phase vier: Aufruflimits

Die Authentifizierungsendpunkte für Anmeldung, Registrierung und Passwortzurücksetzung hatten keine Aufruflimits. Ohne solche Limits akzeptieren diese Endpunkte unbegrenzt viele Anfragen. Das ermöglicht Brute-Force-Angriffe auf Passwörter und die Ermittlung gültiger E-Mail-Adressen anhand unterschiedlicher Antworten.

Aufruflimits für Next.js-API-Routen lassen sich mit einem Redis-gestützten Zähler implementieren; das folgende Beispiel verwendet ein festes Zeitfenster, das einfacher ist als ein gleitendes. Da es INCR und EXPIRE getrennt aufruft, kann ein Fehler zwischen den Aufrufen einen Schlüssel ohne Ablaufzeit hinterlassen. Im Produktivbetrieb sollten daher beide Operationen atomar ausgeführt werden (zum Beispiel in einer Transaktion oder einem Lua-Skript). Als Schlüssel dient meist die IP-Adresse oder bei authentifizierten Endpunkten die Nutzer-ID. Für Authentifizierungsendpunkte ist eine IP-basierte Begrenzung geeignet, da der Nutzer noch nicht authentifiziert ist:

// lib/rateLimit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });

export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
  const key = `rate_limit:${identifier}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

Die gesetzten Limits waren zurückhaltend: Anmeldeversuche werden pro IP-Adresse und Zeitfenster von fünfzehn Minuten begrenzt, Anfragen zur Passwortzurücksetzung pro E-Mail-Adresse und Stunde. Diese Limits sind großzügig genug, dass legitime Nutzer sie bei normaler Verwendung nur selten erreichen sollten, und restriktiv genug, um automatisierte Brute-Force-Angriffe unpraktikabel zu machen.

Phase fünf: Schutz vor Timing-Angriffen

Timing-Angriffe auf Authentifizierungsendpunkte erlauben einem Angreifer festzustellen, ob eine E-Mail-Adresse registriert ist. Dazu misst er den Unterschied der Antwortzeiten zwischen „E-Mail-Adresse nicht gefunden“ und „E-Mail-Adresse gefunden, Passwort falsch“. Eine Suche, die einen Nutzer findet, dauert etwas länger, weil der Passwortvergleich mit bcrypt oder Argon2 nur dann ausgeführt wird. Dieser Unterschied, typischerweise im Bereich von 50–200ms, lässt sich mit ausreichend vielen Anfragen messen.

Die übliche Gegenmaßnahme ist, den Vergleich des Passwort-Hashes immer auszuführen und bei einem nicht vorhandenen Nutzer einen Platzhalter-Hash zu verwenden:

const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';

export async function verifyLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Always run bcrypt comparison to normalize response time
  const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await bcrypt.compare(password, hashToCompare);

  if (!user || !isValid) {
    return null;  // Same code path for both failure cases
  }
  return user;
}

Der obige Wert DUMMY_HASH ist ein Platzhalter: Er ist kein gültiger bcrypt-Hash mit 60 Zeichen. Bibliotheken wie bcryptjs geben bei fehlerhaft formatierten Hashes sofort false zurück, was die Angleichung der Laufzeiten zunichtemachen würde. In der Praxis sollte beim Start einmalig ein echter Hash erzeugt werden (zum Beispiel mit bcrypt.hash() aus einer zufälligen Zeichenfolge), mit demselben Kostenfaktor wie bei den gespeicherten Hashes. Das Problem betraf nicht den integrierten Anmeldeendpunkt von Supabase, sondern trat in einer benutzerdefinierten Authentifizierungsroute auf, die die Anwendung für eine Altsystemintegration ergänzt hatte. Jede eigene Authentifizierungslogik, die nicht die Authentifizierungsfunktionen von Supabase verwendet, muss die Angleichung der Laufzeiten ausdrücklich implementieren.

Was sich geändert hat und was nicht

Alle Befunde aus den fünf Phasen wurden behoben, bevor das Audit als abgeschlossen galt. Die Priorisierung war klar: Die RLS-Fehlkonfiguration und die offene Weiterleitung wurden als kritisch eingestuft und sofort korrigiert. CSP-Deployment und Aufruflimits hatten hohe Priorität und wurden im selben Sprint umgesetzt. Die Angleichung der Laufzeiten auf der Altsystemroute hatte mittlere Priorität und wurde im folgenden Sprint ausgeliefert.

Das Audit führte außerdem zu Prozessänderungen: Bei neuen Tabellen müssen die RLS-Richtlinien geprüft werden, bevor der Pull Request zusammengeführt werden darf. API-Routen verwenden eine gemeinsame Zod-Validierungshülle als Teil der Routenvorlage. Der CSP-Header wird nun aus einer zentralen Konfiguration erzeugt statt aus direkt eingebetteten Zeichenfolgen.

Das wichtigste Ergebnis war keine einzelne Korrektur, sondern die Bestandsaufnahme. Genau zu wissen, welche Daten jede Tabelle enthält, welche RLS-Richtlinie sie schützt und welche API-Routen darauf zugreifen, ist die Grundlage für richtige Sicherheitsentscheidungen bei der Weiterentwicklung der Anwendung. Ohne diese Übersicht bleibt Sicherheit reaktiv: Sie beheben, was Sie finden. Mit ihr wird Sicherheit zu einer Eigenschaft, die Sie gezielt beurteilen können.

Weiterführende Beiträge

Weiterführende Beiträge