Zahlungen über Webhooks: doppelte Rechnungen und eine ungeprüfte Signatur
Kunden bekamen zwei oder drei Rechnungen für eine Zahlung, und Kurszugang ließ sich ohne Zahlung erlangen. Idempotente Verarbeitung und Signaturprüfung.
- Firma
- Verkauf von Onlinekursen und Mitgliedschaften, 7 Personen
- Team
- Zwei Entwickler
- Technik
- Node.js und Express 5, PostgreSQL, Zahlungen über Stripe
- Rahmen
- Die Anwendung stellt Rechnungen selbst aus, die Buchhaltung übernimmt sie monatlich
01Ausgangslage
Nach der Zahlung über Stripe Checkout schickt Stripe das Ereignis checkout.session.completed an /webhooks/stripe. Die Anwendung sucht die Bestellung, markiert sie als bezahlt, schaltet den Kurs frei, erzeugt eine PDF-Rechnung und verschickt sie per E-Mail. Alles in einer Anfrage, während Stripe auf die Antwort wartet.
Die Signaturprüfung war ursprünglich im Code, funktionierte aber nicht, und der Entwickler schaltete sie ab. Die Anwendung nutzte express.json() global, das den Anfragekörper parst, bevor die Prüfung ihn zu sehen bekommt.
- Kunde
- Stripe Checkout
- Stripe: Webhook
- Node.js / Express (Verarbeitung, PDF, E-Mail)
- PostgreSQL
02Das Problem
Beim Monatsabschluss fiel der Buchhaltung auf, dass manche Kunden für eine Zahlung zwei oder drei Rechnungen mit verschiedenen Nummern hatten. Kunden fragten, warum sie mehrere bekommen hatten.
Bei der Ursachensuche stieß der Entwickler auf die abgeschaltete Signaturprüfung, das ernstere Problem. Wer die Webhook-Adresse kannte (und /webhooks/stripe ist leicht zu erraten), konnte ein eigenes Ereignis checkout.session.completed mit der ID seiner unbezahlten Bestellung schicken, und die Anwendung schaltete den Kurs frei.
Voraussetzung: Der Angreifer legt eine Bestellung an, ohne zu zahlen, und schickt ein gefälschtes Ereignis. Mehr als einen Browser und ein Werkzeug für HTTP-Anfragen braucht er nicht.
03Technische Ursache
Stripe liefert Ereignisse mindestens einmal, nicht genau einmal. Kommt nicht rechtzeitig ein 2xx, versucht Stripe es erneut, im Live-Modus bis zu drei Tage, und dasselbe Ereignis kann auch ohne Fehler beim Empfänger mehrfach ankommen. Die Verarbeitung war nicht idempotent: Jede Zustellung stellte eine neue Rechnung aus.
Die Verarbeitung war langsam, weil sie während der Anfrage das PDF erzeugte und die E-Mail verschickte. Dauerte es zu lange, wertete Stripe das als Fehlschlag und schickte das Ereignis erneut, oft noch während der erste Durchlauf lief.
Die Signatur wird über die exakten Bytes des Anfragekörpers berechnet, stripe.webhooks.constructEvent braucht den Körper also unverändert. Nach express.json() bekommt es ein Objekt, die Prüfung scheitert, und statt die Reihenfolge der Middleware zu korrigieren, wurde die Prüfung abgeschaltet.
04Untersuchung
Die Webhook-Übersicht in Stripe zeigt zu jedem Ereignis alle Zustellversuche und die Antworten der Anwendung. Die doppelten Rechnungen passten zu Ereignissen mit mehreren Versuchen, bei denen der erste in den Timeout gelaufen war.
Ob jemand die fehlende Signatur ausgenutzt hatte: Ein Skript ging alle bezahlten Bestellungen durch, holte über die Stripe-API die zugehörige Checkout Session und prüfte, dass payment_status gleich paid ist und der Betrag zur Bestellung passt. Es fand keine Abweichung.
05Behebung
Der Webhook hat eine eigene Route mit express.raw(), registriert vor dem globalen express.json(), und prüft die Signatur. Jedes Ereignis wird in einer Transaktion in eine Tabelle mit eindeutiger ID geschrieben. Steht es schon dort, passiert nichts.
// registered BEFORE app.use(express.json()): the signature covers the raw bytes
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400); // unsigned or altered: not from Stripe
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// delivery is at least once: the event id is the idempotency key
const first = await client.query(
"INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING",
[event.id],
);
if (first.rowCount === 1) await handleEvent(client, event);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err; // 500: Stripe retries later (Express 5 forwards the rejection)
} finally {
client.release();
}
res.sendStatus(200);
});Die eigentliche Verarbeitung markiert die Bestellung nur über ein bedingtes UPDATE als bezahlt, und nur bei passendem Betrag. Rechnung und E-Mail entstehen nicht mehr während der Anfrage: In derselben Transaktion entsteht ein Eintrag in der Tabelle outbox, den ein eigener, minütlich per cron gestarteter Prozess verschickt.
-- inside the same transaction as the event insert
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending' AND amount_cents = $2;
-- only when that UPDATE touched a row: the invoice email waits in an outbox
INSERT INTO outbox (kind, order_id) VALUES ('invoice_email', $1);Bei checkout.session.completed wird auch payment_status geprüft. Bei Zahlarten mit verzögerter Bestätigung kann er noch unpaid sein, die Bestellung wird dann erst mit checkout.session.async_payment_succeeded abgeschlossen.
Nächtlicher Abgleich: Ein Skript vergleicht die Bestellungen der letzten Tage mit der Liste der Checkout Sessions in Stripe. Es fängt auch ein Ereignis ab, das Stripe innerhalb seiner drei Tage nicht zustellen konnte.
Die doppelten Rechnungen korrigierte die Buchhaltung mit Gutschriften. Eine ausgestellte Rechnung lässt sich nicht einfach löschen.
06Warum die Lösung wirkt
Die Signatur belegt, dass das Ereignis von Stripe stammt und unterwegs nicht verändert wurde. Ohne das Webhook-Geheimnis lässt sich keine gültige Signatur erzeugen, ein gefälschtes Ereignis endet mit 400.
Die eindeutige Ereignis-ID in der Datenbank macht eine wiederholte Zustellung harmlos. Kommen zwei Kopien gleichzeitig, wartet das zweite INSERT, bis die erste Transaktion fertig ist, und fügt dank ON CONFLICT DO NOTHING dann nichts ein.
Das bedingte UPDATE ist eine zweite Sicherung: Es ändert keine Bestellung, die nicht mehr pending ist, und akzeptiert keinen abweichenden Betrag.
Die Antwort kommt schnell, weil PDF und E-Mail außerhalb der Anfrage entstehen, Stripe wiederholt also nicht unnötig. Der outbox-Eintrag entsteht in derselben Transaktion wie die Zahlung, die E-Mail geht also auch dann nicht verloren, wenn die Anwendung direkt danach abstürzt.
Was das nicht löst: Der Prozess, der die outbox verschickt, kann eine E-Mail doppelt senden, wenn er zwischen Versand und Vermerk abstürzt. Die Rechnung selbst wird aber nicht doppelt ausgestellt, sie entsteht nur einmal.
07Überprüfung
- Stripe CLI (
stripe listen --forward-toundstripe trigger checkout.session.completed) im Testmodus: ein Ereignis, eine Rechnung. - Das erneute Senden desselben Ereignisses aus der Stripe-Übersicht erzeugte keine zweite Rechnung.
- Eine Anfrage ohne Signatur und eine mit verändertem Körper lieferten beide 400.
- Ein Test, der dasselbe Ereignis zweimal gleichzeitig schickt, endet mit einer einzigen Rechnung.
08Ergebnisse
Behoben
- Eine Zahlung bedeutet eine Rechnung, auch wenn das Ereignis mehrfach ankommt.
- Kurszugang lässt sich nicht mit einem gefälschten Ereignis erlangen.
Risiko gesenkt
- Der Webhook antwortet schnell, Stripe wiederholt nur bei echten Fehlern.
- Der nächtliche Abgleich findet eine Zahlung, deren Ereignis nie ankam.
Weiter offen
- Die Rechnungs-E-Mail kann in seltenen Fällen doppelt ankommen.
09Grenzen
- Das Webhook-Geheimnis ist jetzt der Schlüssel zu den Zahlungen. Es gehört in die Geheimnisverwaltung, nicht ins Repository, und wird gewechselt, wenn jemand mit Zugang geht.
- Ereignisse können in anderer Reihenfolge ankommen, als sie entstanden. Die Verarbeitung muss sich deshalb auf den Zustand der Bestellung stützen, nicht auf die Reihenfolge.
- Der Abgleich deckt nur die letzten Tage ab. Ältere Abweichungen muss die Buchhaltung finden.
10Erkenntnisse
- Webhooks werden mindestens einmal zugestellt. Jede Verarbeitung muss dasselbe Ereignis zweimal verkraften.
- Die Webhook-Signatur wird am unveränderten Anfragekörper geprüft. Scheitert die Prüfung, korrigiert man die Reihenfolge, statt sie abzuschalten.
- Auf Webhooks schnell antworten. Langsame Arbeit gehört außerhalb der Anfrage.
- Das Erfassen des Ereignisses und die Zustandsänderung gehören in eine Transaktion.
- Weder dem Zahlungsanbieter noch der eigenen Anwendung blind vertrauen: einmal täglich abgleichen.
11Nächste Schritte für eine kleine Firma
- 1.Eine Warnung, wenn sich unversandte Einträge in der
outboxstauen. - 2.Den Stripe-Webhook auf die Ereignisse beschränken, die die Anwendung tatsächlich verarbeitet.
- 3.Einen Test mit Stripe CLI in die CI aufnehmen, der den ganzen Zahlungsablauf prüft.
- 4.Beim Wechsel des Webhook-Geheimnisses altes und neues eine Weile parallel gelten lassen.
Erkennen Sie Ihre eigene Firma darin?
Schreiben Sie uns, worum es geht. Wir antworten innerhalb eines Werktags und sagen Ihnen, ob es Arbeit für uns ist, auch wenn die Antwort nein lautet.