Modelový prípad 07Bezpečný vývoj softvéru

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.

  1. Zákazník
  2. Stripe Checkout
  3. Stripe: webhook
  4. Node.js / Express (spracovanie, PDF, e-mail)
  5. 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č.

webhooks.js
// 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.

SQL
-- 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-to a stripe 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. 1.Upozornenie, keď sa v tabuľke outbox hromadia neodoslané záznamy.
  2. 2.Obmedziť v Stripe webhook iba na udalosti, ktoré aplikácia naozaj spracúva.
  3. 3.Pridať do CI test so Stripe CLI, ktorý overí celý tok platby.
  4. 4.Pri zmene tajomstva webhooku postupovať tak, aby staré a nové chvíľu platili súčasne.
Ozvite sa

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.