Cybersicherheit · Bedrohungsmodellierung · Backend · Cloudflare Workers
Bedrohungsmodellierung eines Kontaktformulars: Verteidigung in der Tiefe auf einer sehr kleinen API
Ein Kontaktformular sieht aus wie das langweiligste Feature auf einer Website. Es ist auch ein öffentliches Endpoint, das Text von Fremden akzeptiert und ihn in eine Datenbank und ein Postfach sendet. Hier ist, wie ich das auf dieser Website threat-modelliert habe, Schicht für Schicht, und wo es immer noch schwach ist.
Das Kontaktformular auf dieser Website besteht aus etwa vierzig Zeilen Formular und einer API-Route. Es ist leicht, es als Detail zu behandeln. Aber schauen Sie es aus der anderen Seite: Es ist ein öffentliches Endpoint, das dem ganzen Internet offen steht, das Text von Fremden akzeptiert und ihn in eine Datenbank und ein E-Mail-Postfach schreibt. Genau diese Art von Feature suchen Angreifer, weil niemand es überwacht.
Bevor ich es ausgeliefert habe, habe ich mich hingesetzt und es threat-modelliert. Dieser Beitrag führt durch diesen Prozess: Was könnte schiefgehen, was ich gegen jede Bedrohung gebaut habe und, ebenso wichtig, wo die Konstruktion immer noch schwach ist.

Beginnen Sie mit dem Datenfluss
Bedrohungsmodellierung beginnt mit der Darstellung, wie Daten fließen. Meine ist kurz:
- Ein Besucher füllt das Formular in seinem Browser aus.
- Der Browser sendet JSON an
POST /api/contact, eine kleine Hono-App, die auf Cloudflare Workers läuft. - Der Worker validiert die Eingabe, überprüft auf Missbrauch und speichert die Nachricht in einer D1-(SQLite-)Datenbank.
- Der Worker sendet mir eine Benachrichtigung per E-Mail, mit der Adresse des Besuchers als
Reply-To.
Jeder Pfeil in dieser Liste ist eine Vertrauensgrenze: Browser zu API, API zu Datenbank, API zu E-Mail. Jede davon ist ein Ort, an dem nicht vertrauenswürdige Eingabe in etwas übergeht, das mir wichtig ist. Für jede Grenze habe ich die Fragen hinter dem STRIDE-Modell gestellt: Könnte jemand spoofen, manipulieren, leugnen, was er getan hat, informationen preisgeben, Dienst verweigern oder Rechte erhöhen?
Für ein Kontaktformular ergeben sich drei Bedrohungen: Missbrauch (Spam und Flut), Injektion (in die Datenbank und in die E-Mail) und Privatsphäre (Ich halte jetzt die Namen, E-Mails und Wörter anderer Menschen).
Schicht 1: Nehmen Sie an, der Browser lügt
Die erste Regel ist, dass das Formular im Browser ein Vorschlag und keine Kontrolle ist. Jeder kann es überspringen und Anfragen mit curl senden. Also erfolgt jede Überprüfung noch einmal auf dem Server:
- Der Body muss gültiges JSON sein, oder die Anfrage wird mit einem
400abgelehnt. namemuss vorhanden sein und höchstens 100 Zeichen lang sein.emailmuss einem grundlegenden Muster entsprechen und höchstens 200 Zeichen lang sein.intentmuss einer festen Liste (project,design,teaching,speaking,other) entsprechen. Dies ist eine Zulassungsliste: Ich versuche nicht, zu erraten, wie schlechte Eingabe aussieht, ich akzeptiere nur, wie gute Eingabe aussieht.messagemuss zwischen 10 und 5.000 Zeichen lang sein.
Längenbeschränkungen sind nicht nur eine Frage der Ordnung. Sie setzen eine Obergrenze dafür, wie viel Speicherplatz und E-Mail-Anfragen ein Request verbrauchen kann, was später wichtig wird, wenn wir über Überflutungen sprechen.
Die gleiche Idee gilt für CORS. Die API gibt CORS-Header nur an Ursprünge in einer Zulassungsliste (meine Domain und deren Vorschau-Deployments) zurück. Aber ich zähle CORS hier nicht als Sicherheitskontrolle. Es verhindert, dass andere Websites den Browser eines Besuchers verwenden, um meine API aufzurufen; es tut nichts gegen ein Skript, das direkt mit der API spricht. Zu wissen, welche Kontrolle gegen welchen Angreifer schützt, ist die Hälfte der Arbeit.
Schicht 2: Spam teuer machen
Spam ist der wahrscheinlichste Angriff auf jedes Kontaktformular, daher erhält es drei separate Schichten. Keine davon ist allein ausreichend; zusammen erhöhen sie die Kosten.
Eine Honigfalle. Das Formular hat ein verstecktes company-Feld, das Menschen nie sehen und nie ausfüllen. Einfache Bots füllen jedes Feld aus. Wenn company einen Wert hat, gibt die API { ok: true } zurück und wirft die Nachricht stillschweigend weg. Die Rückgabe eines Erfolgs ist absichtlich: ein Fehler würde dem Autor des Bots beibringen, dass er erwischt wurde.
Eine Ratebegrenzung. Jede IP-Adresse erhält fünf Übermittlungen pro Stunde, die in Cloudflare KV mit einer Gültigkeitsdauer von einer Stunde gezählt werden. Wenn diese Grenze überschritten wird, gibt die API 429 Too Many Requests zurück. Der Zähler wird vor der Bot-Überprüfung genommen, sodass ein Skript, das immer wieder fehlschlägt, immer noch seinen eigenen Kontingent verbraucht.
Eine Herausforderung. Das Formular verwendet Cloudflare Turnstile, und der Server überprüft den Token mit Cloudflare, bevor er etwas speichert. Der geheime Schlüssel lebt in einem Worker-Geheimnis, nie im Repository oder auf der Seite.
Dies ist Verteidigung in der Tiefe im kleinen Maßstab: die Honigfalle fängt faule Bots kostenlos, Turnstile fängt den Rest, und die Ratebegrenzung begrenzt den Schaden, der durch alles entsteht, was durch beide hindurchkommt.
Schicht 3: Injektion, an zwei Stellen, die die Menschen vergessen
Die meisten Entwickler wissen über SQL-Injektion Bescheid. Der Insert in D1 verwendet eine parameterisierte Abfrage:
await c.env.DB.prepare('INSERT INTO messages (name, email, intent, body) VALUES (?, ?, ?, ?)')
.bind(name, email, intent, message)
.run();
Der Text des Besuchers wird als Daten übergeben, nie in die SQL-Zeichenfolge geklebt, sodass eine Nachricht, die '); DROP TABLE messages; -- enthält, genau so gespeichert wird: eine seltsame Nachricht.
Der zweite Injektionspunkt ist weniger berühmt: E-Mail-Header. Die Benachrichtigungs-E-Mail wird von Hand als MIME-Nachricht erstellt, und der Name und die E-Mail-Adresse des Besuchers landen in den Subject- und Reply-To-Headern. E-Mail-Header sind durch Zeilenumbrüche getrennt. Wenn der „Name“ eines Besuchers einen Zeilenumbruch gefolgt von Bcc: [email protected] enthielte, würde eine sorglose Implementierung einen neuen Header hinzufügen und meinen Server in einen Spam-Relay verwandeln. Daher wird jeder Wert, der in einen Header gelangt, durch eine Funktion übergeben, die Zeilenumbrüche durch Leerzeichen ersetzt, und nicht-ASCII-Namen (wie Kɔfi oder 李明) werden ordnungsgemäß codiert und nicht roh übergeben.
Der E-Mail-Text ist einfacher Text und kein HTML, sodass eine Nachricht voller <script>-Tags einfach Text ist, wenn ich sie lese.
Schicht 4: die langweiligen Header, die viel bewirken
Die Website selbst wird mit strengen Sicherheitsheadern bereitgestellt: einer Content Security Policy, die nur Skripte von meiner eigenen Domain und den zwei Cloudflare-Diensten, die ich verwende, frame-ancestors 'none' und X-Frame-Options: DENY gegen Clickjacking, HSTS, damit Browser immer HTTPS verwenden, und ein Permissions-Policy, das die Kamera, das Mikrofon und den Standort deaktiviert. Keines davon ist aufregend, und das ist der Punkt. Gute Sicherheit besteht hauptsächlich aus langweiligen Entscheidungen, die konsequent getroffen werden.
Wo es immer noch schwach ist
Ein Bedrohungsmodell, das keine Schwächen findet, ist kein Bedrohungsmodell. Diese sind diejenigen, über die ich Bescheid weiß:
- IP-basierte Rate Limits bestrafen gemeinsam genutzte Netzwerke. Viele mobile Netzwerke setzen große Nutzerzahlen hinter einer kleinen Gruppe gemeinsam genutzter IP-Adressen (carrier-grade NAT). In einem solchen Netzwerk könnte ein missbräuchlicher Benutzer das Stundenlimit für unschuldige Personen aufbrauchen, die zufällig ihre Adresse teilen. Für eine persönliche Website ist der Kompromiss akzeptabel, aber es ist ein echtes Fairnessproblem für Dienste, die für afrikanische mobile Benutzer entwickelt wurden, und es ist eines, über das ich noch mehr nachdenken möchte.
- Die Rate Limit ist approximativ. KV ist letztendlich konsistent, und das Lesen und Schreiben des Zählers ist nicht atomar, sodass ein schneller Burst paralleler Anfragen leicht past die Limit grenzen kann. Das ist in Ordnung, um Spam zu dämpfen; es wäre jedoch nicht in Ordnung, um beispielsweise Anmeldeversuche zu limitieren, wo ich einen stark konsistenten Speicher verwenden würde.
'unsafe-inline'ist immer noch in der Skript-Richtlinie. Die statische Website verwendet einige Inline-Skripte, sodass die CSP sie zulässt. Dies schwächt den Schutz der CSP gegen Cross-Site-Scripting. Die ordnungsgemäße Lösung besteht darin, zu Hashes oder Nonces für jedes Inline-Skript zu wechseln.- Nachrichten werden unbefristet in Klartext gespeichert. Derzeit gibt es keine Aufbewahrungsrichtlinie. Nach dem ghanaischen Datenschutzgesetz von 2012 (Gesetz 843) und den guten Praktiken überall sollte ich personenbezogene Daten nur so lange aufbewahren, wie ich sie benötige. Ein geplanter Job, der Nachrichten nach einer festen Frist löscht, ist der offensichtliche nächste Schritt.
Ich habe diese vier priorisiert und aufgeschrieben, wie ich plane, jedes davon zu beheben, in einem Folgebeitrag.
Was ich daraus mitgenommen habe
Zwei Dinge sind mir im Gedächtnis geblieben.
Erstens, die Sicherheit beginnt mit der Frage „Wer ist auf der anderen Seite dieses Endpunkts?“, nicht mit einer Liste von Tools. Turnstile, KV und parameterisierte Abfragen sind nur nützlich, weil das Bedrohungsmodell sagte, wo sie eingesetzt werden sollten.
Zweitens, die kleinen, unglamourösen Funktionen sind, wo das Risiko sich versteckt. Niemand testet die Kontaktformulare. Es sind genau die Systeme, die zu klein erscheinen, um wichtig zu sein, ein Schul-Results-Portal, eine Spenden-Seite einer Kirche, ein Anmeldeformular einer kleinen NGO, die reale Personen Daten mit dem geringsten Schutz aufbewahren. Diese Systeme möchte ich am meisten sicherer machen, und sie sind, wo ich meine Forschung hinlenken möchte.
Wenn Sie etwas bemerken, das ich übersehen habe, würde ich mich freuen, wenn Sie es mir mitteilen: Kontakt aufnehmen. Ja, über das Formular.
