Zum Inhalt springen

9. Angriffsvektoren und Schutz

Zu Zen-Modus wechseln

Der Server ist seit Kapitel 2 gehärtet, die Anmeldung steht seit Kapitel 8. Was jetzt fehlt, ist die Anwendung selbst, und sie ist inzwischen die interessanteste Angriffsfläche: Ein Angreifer, der weder das Passwort kennt noch am Server vorbeikommt, greift das an, was Sie ihm freiwillig hinstellen, nämlich Ihre Eingabefelder.

Der Unterschied zu einer Aufzählung von Angriffsarten liegt in der Arbeitsweise dieses Kapitels. Jede Lücke wird im eigenen Projekt gebaut, ausgenutzt und geschlossen, und der Nachweis besteht aus einem Vorher und einem Nachher. Wer eine Schwachstelle einmal selbst ausgenutzt hat, vergisst die Gegenmaßnahme nicht mehr.

Eine Liste abzuarbeiten hilft nur gegen die Punkte auf der Liste. Nützlicher ist die Frage, mit der jeder Angriff beginnt: Wo kommen Daten von außen herein, und was passiert damit?

EintrittspunktBeispiel im ProjektWas schiefgehen kann
FormularfelderBemerkung einer BuchungXSS, überlange Eingaben
URL-Parameter?sort=, ?id=Injection, IDOR
KopfzeilenReferer, User-AgentInjection ins Log, blindes Vertrauen
CookiesSitzungManipulation, Fixation
Datei-UploadsProfilbild, AnhangSchadcode, Speicherfüllung
Fremde SchnittstellenCMS, ZahlungsdienstVertrauen auf fremde Antworten
Redaktionelle InhalteCMS-Seitengespeichertes XSS über ein übernommenes Konto

Dazu vier Fragen, die für ein leichtgewichtiges Bedrohungsmodell genügen und in der 5. Klasse vertieft werden: Was bauen wir? Was kann daran schiefgehen? Was tun wir dagegen? Und haben wir es gut genug gemacht?

Die OWASP Top 10 sind dabei die Landkarte: eine Liste der Schwachstellenklassen, die am häufigsten auftreten und am meisten Schaden anrichten. Sie ist kein Auswendiglernstoff, sondern eine Ordnung für die eigene Prüfung.

Injection heißt: Etwas, das als Daten gemeint war, wird als Anweisung ausgeführt. Der bekannteste Fall ist SQL, das Muster ist überall dasselbe.

Bauen Sie den verwundbaren Endpunkt einmal absichtlich:

// deliberately vulnerable, only for experimenting in your own project
const search = url.searchParams.get("q") ?? "";
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM "Machine" WHERE name LIKE '%${search}%'`
);

Und rufen Sie ihn so auf:

?q=' OR 1=1 --
?q=' UNION SELECT id, email, password_hash FROM "User" --

Die erste Zeile liefert alle Datensätze, die zweite unter Umständen Ihre Benutzertabelle. Genau dieser Moment ist der Grund, warum dieses Kapitel praktisch stattfindet.

Geschlossen wird die Lücke nicht durch Filtern, sondern durch Trennung von Anweisung und Daten:

const results = await prisma.machine.findMany({
where: { name: { contains: search, mode: "insensitive" } },
});

Dasselbe Muster begegnet Ihnen in vier weiteren Varianten. Kommando-Injection entsteht, wenn eine Benutzereingabe in einen Shell-Aufruf wandert; der Ausweg ist, gar keine Shell zu benutzen und Argumente als Liste zu übergeben. Pfad-Traversal entsteht, wenn ein Dateiname aus der Anfrage stammt und ../../etc/passwd enthält; der Ausweg ist, den aufgelösten Pfad gegen ein erlaubtes Verzeichnis zu prüfen. Template-Injection entsteht, wenn Benutzereingaben in eine Vorlagensprache gelangen. Und im Log entsteht ein eigenes Problem, wenn ungeprüfte Eingaben dort landen und Zeilenumbrüche enthalten.

Bei XSS führt der Browser eines anderen Benutzers Code aus, den ein Angreifer über Ihre Anwendung eingeschleust hat. Zusammen mit Kapitel 8 ist das die teuerste Kombination des ganzen Jahres: Wer im Browser eines angemeldeten Benutzers Code ausführt, handelt in dessen Namen und kann bei einem Token im localStorage gleich die ganze Sitzung mitnehmen.

Drei Bauformen, jeweils mit einem Beispiel aus Ihrem Projekt:

Reflektiert. Die Eingabe kommt aus der URL und landet unverändert in der Antwort. Klassisch: eine Suchergebnisseite, die „Keine Treffer für Suchbegriff” anzeigt. Der Angriff braucht einen Link, den das Opfer anklickt.

Gespeichert. Die Eingabe landet in der Datenbank und wird später allen Betrachtern angezeigt, etwa im Bemerkungsfeld einer Buchung. Das ist die gefährlichere Variante, weil kein Link nötig ist und jeder Betrachter betroffen ist.

DOM-basiert. Der Server ist gar nicht beteiligt: JavaScript im Browser liest etwas aus der URL oder aus dem Speicher und schreibt es in die Seite.

React kodiert alles, was als Text in JSX steht, automatisch. {userInput} landet als Text auf der Seite, auch wenn <script> darin vorkommt. Damit ist die häufigste Fehlerquelle ab Werk geschlossen, und genau deshalb wiegt man sich leicht in Sicherheit.

Vier Stellen bleiben offen:

// 1. Injected HTML is executed
<div dangerouslySetInnerHTML={{ __html: userText }} />
// 2. javascript: links in attributes
<a href={userLink}>Website</a> // userLink = "javascript:alert(1)"
// 3. Properties that expect code
<div style={userStyle} />
// 4. Anything that writes into the DOM behind React's back
element.innerHTML = userText;

Der Name der ersten Eigenschaft ist eine Warnung und wird trotzdem regelmäßig benutzt, weil ein Rich-Text-Feld oder ein CMS-Inhalt HTML liefert. Dann führt kein Weg an einer Bereinigung vorbei: Der HTML-Code wird durch eine Bibliothek wie DOMPurify geschickt, die nur eine Positivliste von Elementen und Attributen durchlässt.

import DOMPurify from "isomorphic-dompurify";
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "a", "h2", "h3"],
ALLOWED_ATTR: ["href", "title"],
});

Das gilt auch für Inhalte aus dem CMS. Der Einwand „das schreiben ja nur Redakteure” übersieht zwei Dinge: Redaktionskonten werden übernommen, und Redakteure fügen Einbettungscode von fremden Seiten ein.

Bei Cross-Site Request Forgery bringt eine fremde Seite den Browser des Opfers dazu, eine Anfrage an Ihre Anwendung zu schicken. Der Browser hängt die Cookies automatisch an, weil er nicht wissen kann, dass die Anfrage unerwünscht ist. Genau die Asymmetrie aus Kapitel 5 macht das möglich: Das Senden ist erlaubt, nur das Lesen der Antwort nicht. Für einen Löschvorgang genügt das Senden.

sequenceDiagram
  participant O as Opfer (angemeldet)
  participant F as fremde-seite.example
  participant A as Ihre Anwendung
  O->>F: besucht harmlose Seite
  F->>O: Seite mit verstecktem Formular, sendet automatisch ab
  O->>A: POST /api/buchungen/812/stornierung, Cookie geht mit
  A->>A: Sitzung gültig, Aktion wird ausgeführt
  A->>O: Antwort, die niemand liest

Der Angriff funktioniert nur, wenn die Anmeldung über etwas läuft, das der Browser automatisch mitschickt, also über Cookies. Im Bearer-Token-Modell muss der Angreifer das Token kennen, und das hat er nicht, weil es nicht automatisch mitgeht.

Drei Schutzmaßnahmen, üblicherweise kombiniert:

SameSite=Lax auf dem Sitzungscookie sorgt dafür, dass es bei Anfragen von fremden Seiten nicht mitgeschickt wird. Das deckt den Großteil ab und ist heute Standardverhalten der Browser.

Ein Synchronizer-Token ist der klassische Weg: Der Server legt ein zufälliges Token in die Seite, das Formular schickt es mit, der Server vergleicht. Die fremde Seite kann es nicht kennen, weil sie die Antwort nicht lesen darf.

Beim Double-Submit steht dasselbe Token in einem Cookie und im Formular; der Server prüft nur auf Gleichheit und braucht dafür keinen Sitzungsspeicher.

Server Actions in Next.js prüfen von sich aus die Herkunft der Anfrage und sind damit ab Werk abgesichert. Für eigene Endpunkte gilt das nicht.

Ein paar Kopfzeilen in der Antwort weisen den Browser an, strenger zu sein, als er es von sich aus wäre.

HeaderWirkung
Content-Security-Policylegt fest, welche Quellen für Skripte, Stile, Bilder erlaubt sind
Strict-Transport-Securityerzwingt HTTPS für künftige Aufrufe
X-Content-Type-Options: nosniffverbietet dem Browser, den Inhaltstyp zu erraten
Referrer-Policybegrenzt, wie viel der eigenen URL an fremde Seiten geht
Permissions-Policyschaltet nicht benötigte Browserfunktionen ab (Kamera, Standort)
frame-ancestors in der CSPverhindert das Einbetten in fremde Seiten (Clickjacking)

Die Content-Security-Policy ist die wirksamste und die aufwendigste davon. Sie ist die zweite Verteidigungslinie gegen XSS: Selbst wenn ein Skript eingeschleust wird, führt der Browser es nicht aus, wenn die Richtlinie es nicht erlaubt.

Aufgebaut wird sie schrittweise:

  1. Im Berichtsmodus starten. Content-Security-Policy-Report-Only meldet Verstöße, ohne etwas zu blockieren. Damit sehen Sie, was Ihre Anwendung tatsächlich lädt, ohne sie zu zerlegen.

  2. Verstöße abarbeiten. Externe Schriften, Analysewerkzeuge, eingebettete Videos: Jede Quelle wird geprüft und entweder aufgenommen oder entfernt.

  3. Inline-Skripte beseitigen. Das ist der mühsame Teil. Was übrig bleibt, bekommt eine nonce, die der Server bei jedem Aufruf neu erzeugt.

  4. Umschalten auf Durchsetzung und die Wirkung mit einem eingeschleusten Testskript nachweisen.

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{randomValue}';
style-src 'self';
img-src 'self' data:;
frame-ancestors 'none';
base-uri 'self';

Wo die Header gesetzt werden, ist eine Betriebsfrage. Statische Header gehören in den Reverse Proxy aus Kapitel 3, weil sie dann für alle Dienste gelten, auch für das CMS. Alles, was pro Anfrage unterschiedlich ist, allen voran die nonce, muss aus der Anwendung kommen. Prüfen lässt sich das Ergebnis mit dem Mozilla Observatory oder schlicht mit curl -I.

Ein Geheimnis ist alles, womit sich Ihre Anwendung gegenüber etwas anderem ausweist: Datenbankpasswörter, API-Schlüssel, Signaturschlüssel für Tokens, Zugangsdaten für den Mailversand.

Sie lecken an fünf Stellen, und alle fünf sind in diesem Kurs schon vorgekommen: im Repository (Kapitel 1), im ausgelieferten JavaScript über das NEXT_PUBLIC_-Präfix (Kapitel 3), im Image (Kapitel 3), in Logs und Fehlerseiten (Kapitel 7) und in Bildschirmfotos in der eigenen Dokumentation.

Verwaltet werden sie in Dokploy als Umgebungsvariablen je Umgebung. Zwei Handgriffe kommen dazu: Prüfen Sie die Git-Historie mit einem Werkzeug wie gitleaks auf versehentlich committete Geheimnisse, und legen Sie fest, wie ein Schlüssel gewechselt wird. Denn wenn eines abhandenkommt, ist die Reihenfolge immer dieselbe: zuerst wechseln, dann aufräumen. Ein Schlüssel, der aus der Historie entfernt, aber nicht gewechselt wurde, ist weiterhin gültig, und der Klon von letzter Woche liegt bei jemand anderem.

Ein durchschnittliches Next.js-Projekt bringt mehrere hundert Pakete mit, die meisten davon indirekt. Jedes davon läuft mit denselben Rechten wie Ihr Code.

Terminal-Fenster
npm audit # known vulnerabilities in the dependencies
npm ls --all | wc -l # how many packages there actually are

Was daraus folgt, sind vier Gewohnheiten. Die Sperrdatei (package-lock.json) gehört ins Repository und in den Build, damit überall dieselben Versionen landen; im Container wird mit npm ci installiert, nicht mit npm install. Sicherheitsmeldungen werden regelmäßig angesehen und nicht gesammelt. Vor der Aufnahme eines neuen Pakets lohnen dreißig Sekunden: Wann wurde es zuletzt aktualisiert, wie viele Abhängigkeiten bringt es mit, heißt es tatsächlich so, wie Sie denken? Und Installationsskripte fremder Pakete laufen mit Ihren Rechten, weshalb npm ci --ignore-scripts in automatisierten Umgebungen eine Überlegung wert ist.

Der Begriff dafür ist Lieferkette, und der Punkt daran: Ein Paket aufzunehmen ist keine technische Bequemlichkeit, sondern eine Vertrauensentscheidung.

Fehlkonfiguration ist die stillste Schwachstelle: ein aktiver Debugmodus in der Produktion, eine Verwaltungsoberfläche ohne Passwort, ein Standardkonto, das nie geändert wurde. Die Dokploy-Oberfläche aus Kapitel 3 gehört hier ausdrücklich dazu.

Unsichere Weiterleitungen entstehen durch Parameter wie ?next=, die ungeprüft übernommen werden. Ein Angreifer verlinkt dann auf Ihre Anmeldeseite und leitet nach dem Anmelden auf seine eigene Seite weiter. Erlaubt sind ausschließlich Ziele auf der eigenen Origin.

Datei-Uploads brauchen vier Regeln: Typ am Inhalt prüfen und nicht an der Endung, unter einem selbst erzeugten Namen speichern, außerhalb des ausgelieferten Verzeichnisses ablegen und beim Ausliefern einen festgelegten Inhaltstyp setzen. Eine hochgeladene Datei mit dem Originalnamen im öffentlichen Ordner ist die einfachste Art, fremden Code auf den eigenen Server zu bringen.

Informationslecks in Fehlermeldungen sind in Kapitel 7 bereits geregelt: knappe Antwort nach außen, Details ins Log.

Jede Lücke, die Sie in diesen zwei Wochen finden, wird nach demselben Muster festgehalten. Diese Sammlung ist die Grundlage für das Sicherheits-Review in Kapitel 16.

## Befund 03: Gespeichertes XSS im Bemerkungsfeld
Schwere: hoch
Gefunden am: 2027-01-15
### Ausnutzung
Buchung mit Bemerkung `<img src=x onerror=alert(document.cookie)>` angelegt.
Beim Aufruf der Belegungsliste durch ein anderes Konto wurde das Skript
ausgeführt (Bildschirmfoto: docs/befunde/03-vorher.png).
### Ursache
Die Bemerkung wurde über dangerouslySetInnerHTML gerendert, um Zeilenumbrüche
darzustellen.
### Behebung
Darstellung als Text mit CSS `white-space: pre-line`. Zusätzlich CSP mit
`script-src 'self' 'nonce-...'` aktiviert.
### Nachweis
Derselbe Eintrag wird jetzt als Text angezeigt (docs/befunde/03-nachher.png),
CSP blockiert ein testweise eingeschleustes Skript (Konsolenausgabe im Anhang).
  1. Filtern statt parametrisieren. Eine Sperrliste muss alle Umgehungen kennen, der Angreifer nur eine.
  2. Sich auf React verlassen. dangerouslySetInnerHTML, href aus Benutzereingaben und direkte DOM-Zugriffe umgehen den Schutz.
  3. Benutzer-HTML ungebereinigt anzeigen, weil es ja nur von Redakteuren kommt.
  4. CSP mit unsafe-inline oder unsafe-eval. Vorhanden, sieht gut aus, wirkt nicht.
  5. Uploads im öffentlichen Verzeichnis unter dem Originalnamen. Der kürzeste Weg zu fremdem Code auf dem eigenen Server.
  6. Geheimnis aus der Historie löschen, ohne es zu wechseln. Es bleibt gültig, und die Klone bleiben verteilt.
  7. npm install im Container statt npm ci, wodurch andere Versionen landen als getestet.
  8. Weiterleitungsziel aus einem Parameter übernehmen, ohne es auf die eigene Origin einzugrenzen.
  9. Sicherheitsarbeit als einmalige Aktion. Nach dem nächsten Feature gilt der Stand von heute nicht mehr.
  • Analysieren: die Eintrittspunkte des eigenen Projekts benennen und den Wert der dahinterliegenden Daten einschätzen.
  • Anwenden: eine SQL-Injection im eigenen Projekt nachstellen, ausnutzen und durch parametrisierte Abfragen schließen.
  • Erklären: begründen, warum Zeichenkettenfilterung der falsche Ansatz ist.
  • Anwenden: reflektiertes, gespeichertes und DOM-basiertes XSS erzeugen und beheben.
  • Erklären: benennen, welche XSS-Fälle React abfängt und welche vier Stellen offen bleiben.
  • Anwenden: Benutzer-HTML mit einer Positivliste bereinigen.
  • Erklären: den Ablauf eines CSRF-Angriffs erklären und begründen, warum er nur im Cookie-Modell greift.
  • Anwenden: CSRF-Schutz über SameSite und ein Synchronizer-Token umsetzen.
  • Erschaffen: eine Content-Security-Policy vom Berichtsmodus bis zur Durchsetzung aufbauen und ihre Wirkung nachweisen.
  • Analysieren: Geheimnisse im Repository, im Bundle und in Logs aufspüren und den Ablauf bei einem Leck beschreiben.
  • Analysieren: verwundbare Abhängigkeiten ermitteln und die Aufnahme eines Pakets als Vertrauensentscheidung begründen.
  • Beurteilen: Restrisiken benennen und Schutzmaßnahmen gegen ihren Aufwand abwägen.
  • Mindestens vier Befunde sind nach dem oben gezeigten Muster dokumentiert, mit Ausnutzung, Ursache, Behebung und Nachweis.
  • Injection, XSS und CSRF wurden im eigenen Projekt nachgestellt und geschlossen.
  • Security-Header sind aktiv, die CSP ist durchgesetzt und kommt ohne unsafe-inline aus.
  • npm audit läuft ohne offene Meldungen hoher Schwere, die Sperrdatei liegt im Repository.
  • Uploads (falls vorhanden) prüfen den Typ am Inhalt, speichern unter eigenem Namen und liegen außerhalb des ausgelieferten Verzeichnisses.
  • Die Befundsammlung liegt in docs/befunde/ und ist die Grundlage für Kapitel 16.
  • Aufgabe - SQL-Injection bauen, ausnutzen, schließen
  • Aufgabe - XSS in drei Varianten
  • Aufgabe - CSRF gegen die eigene Anwendung
  • Aufgabe - CSP vom Berichtsmodus bis zur Durchsetzung