Pourquoi la troisième échéance d’un abonnement e-learning en trois fois s’affichait-elle, dans le tableau de bord comptable, à un euro au lieu des soixante-dix-neuf euros contractuels ? C’est l’anomalie remontée par l’équipe financière d’une plateforme de formation en ligne, qui proposait un paiement fractionné pour ses parcours certifiants les plus onéreux. Un seul compte apparaissait concerné dans un premier temps, ce qui a orienté l’investigation vers une action isolée plutôt qu’un bug généralisé du module de paiement.
L’analyse du code du module de paiement échelonné, développé sur mesure pour s’intégrer à Stripe, a permis de comprendre le mécanisme exact de la falsification, avant de le corriger par une validation serveur systématique à chaque étape du parcours de paiement, plutôt qu’un simple contrôle à la première échéance.
Le montant transmis par le client, réutilisé sans vérification
Le formulaire de paiement échelonné transmettait, à chaque échéance, un montant calculé côté JavaScript à partir du prix du parcours et du nombre d’échéances restantes, dans un champ caché du formulaire. Le code serveur qui traitait la soumission faisait alors confiance à ce champ pour créer l’intention de paiement Stripe correspondante :
function traiter_echeance_paiement( WP_REST_Request $request ) {
$montant = $request->get_param( 'montant' ); // transmis par le client
$abonnement_id = $request->get_param( 'abonnement_id' );
$intention = \Stripe\PaymentIntent::create( array(
'amount' => $montant * 100,
'currency' => 'eur',
'customer' => recuperer_client_stripe( $abonnement_id ),
) );
return $intention;
}
Rien dans ce code ne recalcule le montant attendu à partir d’une source fiable. Le champ caché du formulaire, censé refléter le calcul fait côté JavaScript, pouvait être modifié directement dans les outils de développement du navigateur avant soumission, changeant la valeur de soixante-dix-neuf euros à un euro sans que le serveur ne s’en aperçoive à aucun moment du parcours.
Recalculer chaque échéance depuis la source

La correction retenue supprime toute confiance dans un montant transmis par le client, et recalcule systématiquement l’échéance attendue à partir des données d’abonnement stockées côté serveur :
function traiter_echeance_paiement( WP_REST_Request $request ) {
$abonnement_id = absint( $request->get_param( 'abonnement_id' ) );
$abonnement = get_post_meta( $abonnement_id, 'plan_paiement', true );
if ( ! $abonnement || $abonnement['echeances_restantes'] <= 0 ) {
return new WP_Error( 'abonnement_invalide', 'Aucune échéance en attente.', array( 'status' => 400 ) );
}
$montant_attendu = $abonnement['montant_par_echeance']; // calculé et stocké à la souscription
$intention = \Stripe\PaymentIntent::create( array(
'amount' => $montant_attendu * 100,
'currency' => 'eur',
'customer' => recuperer_client_stripe( $abonnement_id ),
) );
return $intention;
}
Le montant de chaque échéance est désormais fixé une seule fois, au moment de la souscription initiale, stocké en meta de l’abonnement, et relu à chaque échéance plutôt que recalculé ou accepté depuis la requête entrante. Aucun champ transmis par le client n’intervient plus dans le calcul du montant réellement facturé.
Vérifier aussi l’ordre des échéances
L’audit a révélé un second problème lié : rien n’empêchait non plus d’appeler la route de paiement pour une échéance déjà réglée, ou de sauter directement à la dernière échéance sans passer par les précédentes. Le correctif complet vérifie donc, à chaque appel, que le nombre d’échéances déjà réglées correspond bien à la progression attendue avant de créer une nouvelle intention de paiement.
Journaliser chaque étape du parcours
Au-delà du correctif immédiat, l’équipe a ajouté une journalisation systématique de chaque création d’intention de paiement, avec le montant calculé, l’identifiant d’abonnement et l’utilisateur concerné :
- Chaque échéance créée est tracée avec son montant attendu, indépendamment de son résultat final.
- Un écart entre deux échéances consécutives d’un même abonnement, hors changement de formule documenté, déclenche une alerte manuelle.
- L’historique complet permet de vérifier rétroactivement qu’aucun autre abonnement n’avait subi la même anomalie.
Cette journalisation a permis de confirmer que seul le compte initialement signalé avait exploité la faille, sans qu’il soit possible de déterminer avec certitude s’il s’agissait d’une découverte fortuite ou d’un test délibéré des champs cachés du formulaire.
Un montant qui traverse le réseau depuis le navigateur jusqu’au serveur doit toujours être considéré comme une proposition, jamais comme une vérité à exécuter telle quelle.
Pour aller plus loin
Ce type de faille de logique métier ne relève d’aucune vulnérabilité connue référencée publiquement : elle est propre à l’implémentation du module de paiement échelonné lui-même. Elle rappelle une règle générale qui dépasse largement Stripe ou WordPress : toute donnée qui détermine un montant financier, une quantité ou un droit d’accès doit être recalculée ou vérifiée côté serveur à chaque étape sensible, indépendamment de ce qu’affiche l’interface côté client.