Platby cez webhooky: dvojité faktúry a neoverený podpis
Zákazníci dostávali dve a tri faktúry za jednu platbu a o prístup ku kurzu sa dalo požiadať aj bez platby. Idempotentné spracovanie a overenie podpisu.
- Firma
- Predaj online kurzov a permanentiek, 7 ľudí
- Tím
- Dvaja vývojári
- Technológie
- Node.js a Express 5, PostgreSQL, platby cez Stripe
- Obmedzenia
- Faktúry vystavuje priamo aplikácia, účtovníčka ich preberá raz mesačne
01Východiskový stav
Po zaplatení cez Stripe Checkout pošle Stripe na adresu /webhooks/stripe udalosť checkout.session.completed. Aplikácia v nej nájde objednávku, označí ju ako zaplatenú, sprístupní kurz, vygeneruje faktúru v PDF a pošle ju e-mailom. Všetko počas jednej požiadavky, kým Stripe čaká na odpoveď.
Overenie podpisu udalosti v kóde pôvodne bolo, ale nefungovalo a vývojár ho vypol. Aplikácia totiž globálne používala express.json(), ktorý telo požiadavky rozparsuje skôr, než sa k nemu overenie dostane.
- Zákazník
- Stripe Checkout
- Stripe: webhook
- Node.js / Express (spracovanie, PDF, e-mail)
- PostgreSQL
02Problém
Účtovníčka si pri mesačnom spracovaní všimla, že niektorí zákazníci majú za jednu platbu dve alebo tri faktúry s rôznymi číslami. Zákazníci sa pýtali, prečo im prišlo viac faktúr.
Pri hľadaní príčiny vývojár narazil na vypnuté overenie podpisu, čo bol vážnejší problém. Ktokoľvek, kto pozná adresu webhooku (a /webhooks/stripe sa dá ľahko uhádnuť), mohol poslať vlastnú udalosť checkout.session.completed s ID svojej nezaplatenej objednávky a aplikácia by mu kurz sprístupnila.
Predpoklad útoku: útočník si vytvorí objednávku, ale nezaplatí, a pošle falošnú udalosť. Nepotrebuje nič iné než prehliadač a nástroj na posielanie HTTP požiadaviek.
03Technická príčina
Stripe doručuje udalosti najmenej raz, nie práve raz. Ak nedostane včas odpoveď 2xx, pokúša sa znova, v ostrej prevádzke až tri dni, a tá istá udalosť môže prísť aj viackrát bez chyby na strane príjemcu. Spracovanie nebolo idempotentné: každé doručenie vystavilo novú faktúru.
Spracovanie bolo pomalé, lebo počas požiadavky generovalo PDF a posielalo e-mail. Keď trvalo príliš dlho, Stripe to vyhodnotil ako neúspech a poslal udalosť znova, často ešte kým prvé spracovanie bežalo.
Podpis sa počíta z presných bajtov tela požiadavky. stripe.webhooks.constructEvent preto potrebuje nezmenené telo. Po express.json() dostane už objekt, overenie zlyhá, a namiesto opravy poradia sa overenie vyplo.
04Vyšetrenie
V prehľade webhookov v Stripe je pri každej udalosti vidieť všetky pokusy o doručenie aj odpovede aplikácie. Duplicitné faktúry zodpovedali udalostiam s viacerými pokusmi, pri ktorých prvý skončil vypršaním času.
Či niekto zneužil chýbajúci podpis: skript prešiel všetky zaplatené objednávky a pre každú cez API Stripe načítal zodpovedajúcu Checkout Session a overil, že má payment_status rovný paid a sumu zhodnú s objednávkou. Nezhodu nenašiel.
05Náprava
Webhook má vlastnú trasu s express.raw(), zaregistrovanú pred globálnym express.json(), a overuje podpis. Každá udalosť sa v transakcii zapíše do tabuľky s jedinečným ID. Ak už tam je, nerobí sa nič.
// 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);
});Samotné spracovanie označí objednávku za zaplatenú iba podmieneným UPDATE a iba pri súhlasnej sume. Faktúra a e-mail sa nevyrábajú počas požiadavky: v tej istej transakcii vznikne záznam v tabuľke outbox a odošle ho samostatný proces spúšťaný cronom každú minútu.
-- 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);Pri udalosti checkout.session.completed sa kontroluje aj payment_status. Pri platobných metódach s oneskoreným potvrdením môže byť ešte unpaid a objednávka sa vtedy uzavrie až pri udalosti checkout.session.async_payment_succeeded.
Nočné párovanie: skript porovná objednávky za posledné dni so zoznamom Checkout Sessions v Stripe. Zachytí aj udalosť, ktorú sa Stripe za tri dni nepodarilo doručiť.
Duplicitné faktúry účtovníčka opravila dobropismi. Vystavenú faktúru nemožno jednoducho zmazať.
06Prečo oprava funguje
Podpis dokazuje, že udalosť poslal Stripe a nikto ju cestou nezmenil. Bez tajomstva webhooku sa platný podpis vyrobiť nedá, takže falošná udalosť skončí chybou 400.
Jedinečné ID udalosti v databáze robí z opakovaného doručenia neškodnú vec. Keď prídu dve kópie naraz, druhý INSERT počká, kým prvá transakcia skončí, a potom vďaka ON CONFLICT DO NOTHING nevloží nič.
Podmienený UPDATE je druhá poistka: objednávku, ktorá už nie je v stave pending, nezmení, a nesúhlasnú sumu neprijme.
Odpoveď príde rýchlo, lebo PDF a e-mail sa robia mimo požiadavky. Stripe tak zbytočne neopakuje doručenie. Záznam v outbox vzniká v tej istej transakcii ako platba, takže e-mail sa nestratí ani vtedy, keď aplikácia hneď potom spadne.
Čo to nerieši: proces, ktorý odosiela outbox, môže po páde medzi odoslaním a zápisom odoslať e-mail dvakrát. Faktúru však nevystaví druhýkrát, tá vzniká iba raz.
07Overenie
- Stripe CLI (
stripe listen --forward-toastripe trigger checkout.session.completed) v testovacom režime: jedna udalosť, jedna faktúra. - Opätovné odoslanie tej istej udalosti z prehľadu v Stripe nevytvorilo druhú faktúru.
- Požiadavka bez podpisu a požiadavka so zmeneným telom vrátili 400.
- Test, ktorý pošle tú istú udalosť dvakrát súbežne, skončí jednou faktúrou.
08Výsledky
Opravené
- Jedna platba znamená jednu faktúru, aj keď udalosť príde viackrát.
- Kurz sa nedá sprístupniť falošnou udalosťou.
Znížené riziko
- Odpoveď na webhook je rýchla, takže Stripe opakuje doručenie iba pri skutočnej chybe.
- Nočné párovanie zachytí platbu, ktorej udalosť sa nedoručila.
Ostáva otvorené
- E-mail s faktúrou môže výnimočne prísť dvakrát.
09Obmedzenia
- Tajomstvo webhooku je teraz kľúčom k platbám. Patrí do správy tajomstiev, nie do repozitára, a po odchode človeka s prístupom sa má vymeniť.
- Udalosti môžu prísť v inom poradí, než vznikli. Spracovanie sa preto nesmie spoliehať na poradie, ale na stav objednávky.
- Párovanie kontroluje iba posledné dni. Staršie nezhody musí nájsť účtovníctvo.
10Poučenia
- Webhooky sa doručujú najmenej raz. Každé spracovanie musí zvládnuť tú istú udalosť dvakrát.
- Podpis webhooku sa overuje z nezmeneného tela požiadavky. Keď overenie nefunguje, opravuje sa poradie, nie sa vypína.
- Na webhook treba odpovedať rýchlo. Pomalú prácu robte mimo požiadavky.
- Záznam o udalosti a zmena stavu patria do jednej transakcie.
- Platobnej bráne neverte naslepo ani vlastnej aplikácii: raz denne ich porovnajte.
11Ďalšie kroky pre malú firmu
- 1.Upozornenie, keď sa v tabuľke
outboxhromadia neodoslané záznamy. - 2.Obmedziť v Stripe webhook iba na udalosti, ktoré aplikácia naozaj spracúva.
- 3.Pridať do CI test so Stripe CLI, ktorý overí celý tok platby.
- 4.Pri zmene tajomstva webhooku postupovať tak, aby staré a nové chvíľu platili súčasne.
Spoznávate v tom svoju firmu?
Napíšte nám, čoho sa to týka. Ozveme sa do jedného pracovného dňa a povieme, či je to práca pre nás, aj keď odpoveď bude nie.