Éviter qu'une requête POST soit exécutée deux fois

J'ai quelques anomalies sur mon site qui ne peuvent s'expliquer que si une même requête POST est envoyée presque à la suite, en fait avant même que la première soit complètement exécutée.

Pour vous donner une idée, cette requête déclenche six opérations d'écriture en base de donnée que je journalise par ailleurs. Une première a été faite à 57 secondes et 159200 microsecondes, la deuxième à 160900 µs, une troisième à 163600 µs, puis avant la quatrième attendue, j'ai une autre opération qui reprend la première toujours dans la 57e seconde mais à 443800 µs. Les deux demandes ont été lancées à 0,28 secondes d'écart.

C'est un problème rare, que je n'ai pas pu reproduire, mais qui ne semble pas tout à fait isolé. Il semble que, parfois, le navigateur peut envoyer deux requêtes POST l'une à la suite de l'autre, peut-être un double clic sur le bouton d'envoi. J'ai trouvé une discussion sur StackOverflow évoquant la problématique.

Je m'interroge sur la solution à apporter.

Il est évoqué la possibilité de mettre un code en JS pour désactiver le bouton d'envoi dès après le premier click. Les inconvénients soulevés ne me dérangent pas trop, il me semble rare que JS soit désactivé (ce qui poserait d'autres problèmes par ailleurs sur mon site), et je veux surtout éviter une fausse manip' plutôt que prévenir un acte malveillant (le fait que ça soit géré côté client ne me dérange pas). Par contre, point positif, ce serait très simple à rajouter, je pourrais même faire un script global qui s'adapte à tout button de type submit d'un form avec method="post".

Je me demande surtout si c'est suffisamment efficace.

L'autre solution proposée serait de créer un jeton de type GUID ou autre, de le stocker côté serveur, et de le fournir au formulaire via un champ caché. Lorsque le formulaire est soumis, si le jeton existe côté serveur, le jeton est supprimé et le script se poursuit, si le jeton est absent le script génère une erreur. Cela demande plus de travail pour corriger cela sur tout le projet, mais si je comprends bien le mécanisme, cela pourrait en même temps permettre d'éviter les attaques de type CSRF (que je gère actuellement par un jeton de session).

Est-ce que ce mécanisme est également efficace pour se prémuniqu d'une attaque CRSF ?

Où préconisez-vous d'enregistrer ce jeton ? en BDD dans la session ?
 
Bonjour, l'assistance IA, il n'y a rien de mieux. C'est comme si tu travaillais en perpétuel pair-programming
Il pourra aussi te conseiller pour renforcer ton code. C'est fini les tuto youtube et les sites StackOverFlow !
Tu n'as plus à t'user les yeux à rechercher des infos sur le web qui en définitif, ne répondent pas à ce que tu recherches ou qu'il n'y a pas la réponse escomptée. C'est un gain de temps énorme.
L'essayer, c'est l'adopter ;)
 
Jusqu'à présent, je n'ai guère été convaincu. Les solutions proposées ont toujours nécessité d'être retravaillées, parfois assez profondément.

Pour le test, j'ai soumis mon post à ChatGPT, histoire de voir, il m'apporte deux trois infos, mais rien de fondamental, au moins je peux nommer ce que je cherche à faire : déduplication des requêtes et jeton d'idempotence. Par contre, dans sa solution, il me propose de garder trace des jetons utilisés (pourquoi pas, j'étais plutôt parti sur une solution inverse), de les générer en l'insérant dans en BDD et en s'assurant, par une contrainte d'unicité, que la clé n'y existe pas déjà. Son schéma de table est le suivant :
SQL:
CREATE TABLE processed_requests (
    idempotency_key VARCHAR(128) PRIMARY KEY,
    user_id         BIGINT NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    status          VARCHAR(20) NOT NULL,
    response_data   TEXT NULL
);

Déjà, il ne s'assure pas en amont que le jeton n'existe pas lors de sa création, il est simplement généré aléatoirement. Mais bon, on peut estimer que sur un VARCHAR(128) l'entropie est suffisante.

Sur la partie table il créée beaucoup de colonnes qui n'ont aucun intérêt par rapport à la question. Finalement la seule colonne idempotency_key suffit pour le besoin, created_at peut encore être vaguement utile dans ce contexte, mais est trompeur, ce n'est pas le timestamp auquel le jeton est créé, mais celui auquel il est consommé. Par contre, pourquoi un user_id alors que la requête n'est pas nécessairement faite par un utilisateur logué ? Que doit comprendre la colonne status ? pourquoi un VARCHAR ? et je passe sur response_data encore moins sensé et avec un TEXT…

Certes, le discours n'est pas de dire que l'IA ne nécessite pas de relecture, mais tout de même…

Une de mes interrogations c'est la viabilité d'utiliser des jetons d'idempotence à la place du jeton anti-CSRF. Et là dessus la réponse de l'IA n'est pas du tout satisfaisante. Il évoque le cas des jetons CSRF généré page par page plutôt que par session, et me ressort l'argument d'OWASP sur le rejet de demandes légitimes faites en parallèle. Sauf que dans l'architecture que je propose, il pourrait y avoir plusieurs jetons d'unicité créés pour un utilisateur, de sorte que ce problème ne me semble pas exister. Bref, il comprend mal la question mais offre la solution que son modèle d'apprentissage lui permet de ressortir.
 
Réflexion faite, combiner un jeton anti-CSRF et un jeton d'idempotence ne me semble pas une si bonne idée.

Il faut que l'opération de vérification et de consommation du jeton d'idempotence soit atomique, et je n'ai pas vraiment confiance dans la session PHP pour cela, ce qui revient à utiliser la base de donnée.

Or, pour la lutte contre le CSRF, il faut que le jeton soit lié à l'utilisateur ou à sa session. C'est bien sur faisable en rajoutant une colonne sur la table, mais du coup ça rend l'examen plus complexe. Si on utilise l'id de l'utilisateur, ça nécessite une autre approche pour les cas où on vise une opération sur un utilisateur non logué, si on utilise un id de session, finalement on revient à la nécessité d'avoir un jeton de session en plus.

Enfin, ce mécanisme nécessite de créer le jeton lors de la génération du formulaire et de le marquer comme consommé (ou le détruire) lors de la soumission du formulaire, soit deux opérations d'écriture en BDD (outre une inscription inutile pour chaque page de formulaire ouverte et non envoyé). À l'inverse, si le jeton ne sert que pour l'idempotence, je n'ai besoin de l'enregistrer que lors de sa consommation, donc une seule opération en BDD.

Deux autres arguments moins déterminants :

Un bon jeton CSRF doit être généré de façon sécurisée, ce n'est pas nécessaire pour un jeton d'idempotence et exclurait les UUID et systèmes similaires.

Je compare la valeur du jeton CSRF avec hash_equals(), bien que ce soit peut être un peu paranoïaque sur cette opération.

Du coup je me contente d'une table en BDD avec 2 colonnes :
  • CHAR 32 pour stocker un jeton lorsqu'il est utilisé, qui sert aussi de clé primaire
  • UNSIGNED INT pour le timestamp de son utilisation

À terme, je pourrai envisager un cron pour supprimer les jetons utilisés depuis un certain temps. Pour cette fonction 24 heures devraient suffire.

Lors de la soumission du formulaire, j'insère le jeton dans la table avec un INSERT IGNORE et je compte le nombre de lignes avec mysqli_affected_rows() qui devrait retourner 1 si l'insertion a pu avoir lieu.
 

➡️ Offre MyRankingMetrics ⬅️

pré-audit SEO gratuit avec RM Tech (+ avis d'expert)
coaching offert aux clients (avec Olivier Duffez ou Fabien Faceries)

Voir les détails ici

coaching SEO
Discussions similaires
Haut