Un centime au lieu de cent euros : c’est le montant réellement débité lors d’un test d’intrusion mené sur un module de don personnalisé, alors que l’interface affichait fièrement le montant choisi par le généreux testeur. La différence entre les deux ne tenait qu’à une requête modifiée avant son envoi à l’API Stripe.
Ce module avait été développé sur mesure pour une association, avec un formulaire proposant des montants prédéfinis et un champ libre pour saisir une somme personnalisée. Le fonctionnement semblait irréprochable côté visiteur : impossible de saisir un montant négatif ou nul dans le champ, validation JavaScript immédiate, message d’erreur clair. Le problème ne se situait pas dans cette couche visible, mais dans celle qui suivait.
Où se cachait la confiance mal placée
Le formulaire envoyait le montant choisi par le visiteur dans le corps de la requête vers un point de terminaison REST personnalisé, qui créait ensuite le PaymentIntent Stripe correspondant côté serveur. Jusque-là, rien d’anormal : c’est le schéma d’intégration recommandé, où la clé secrète Stripe reste côté serveur et où seul un jeton client (client_secret) transite vers le navigateur pour finaliser le paiement.
Le problème tenait dans un seul appel PHP : le montant transmis par le visiteur était utilisé tel quel pour créer le PaymentIntent, sans jamais être recomparé à une liste de montants autorisés ni recalculé côté serveur. Rien n’empêchait donc de modifier ce montant avant l’envoi, via les outils de développement du navigateur ou un simple client HTTP, tout en laissant l’interface afficher la somme initialement choisie.
Pourquoi cette confiance semble raisonnable au premier abord
Le validation côté client donne une fausse impression de sécurité : elle empêche effectivement un visiteur ordinaire de saisir un montant absurde par erreur, ce qui couvre l’écrasante majorité des usages réels du formulaire. Mais toute validation exécutée dans le navigateur reste, par nature, sous le contrôle total de la personne qui utilise ce navigateur. Un champ désactivé, une contrainte min ou max en HTML, une vérification JavaScript avant soumission : aucun de ces mécanismes ne protège contre une requête forgée directement, en dehors de l’interface prévue.

Le correctif : ne jamais faire confiance à un montant reçu
La correction a consisté à séparer strictement deux rôles : le navigateur choisit une intention de montant, le serveur seul décide du montant réellement facturé. Concrètement, la route REST recalcule ou valide systématiquement le montant reçu avant toute création de PaymentIntent :
register_rest_route('dons/v1', '/creer-intention', [
'methods' => 'POST',
'callback' => function (WP_REST_Request $request) {
$montant_centimes = (int) $request->get_param('montant');
if ($montant_centimes < 500 || $montant_centimes > 500000) {
return new WP_Error('montant_invalide', 'Montant hors limites autorisées.', ['status' => 400]);
}
$intent = \Stripe\PaymentIntent::create([
'amount' => $montant_centimes,
'currency' => 'eur',
'metadata' => ['origine' => 'formulaire_don_site'],
]);
return ['client_secret' => $intent->client_secret];
},
'permission_callback' => '__return_true',
]);
Les bornes minimale et maximale, définies côté serveur, correspondent aux limites métier réellement souhaitées par l’association, indépendamment de ce que l’interface propose visuellement.
Vérifier qu’un formulaire de paiement résiste à ce test
Le test qui a révélé cette faille ne demandait aucun outil sophistiqué : ouvrir les outils de développement du navigateur, repérer la requête envoyée lors de la soumission du formulaire, la rejouer avec un montant modifié via l’onglet réseau, puis observer si le paiement final correspond au montant modifié ou au montant initialement affiché. Ce test simple devrait faire partie de toute recette de module de paiement personnalisé, avant même de considérer la conformité PCI ou la gestion des remboursements.
Notre verdict
Un montant choisi par un visiteur reste une intention, jamais une donnée de confiance. La règle qui en découle est générale et dépasse largement le cas de Stripe : tout champ modifiable par un utilisateur, dès lors qu’il influence une action ayant une conséquence financière ou irréversible, doit être revalidé intégralement côté serveur, sans exception liée à la présence d’une validation côté client déjà en place.