PrestaShop 9.2.1 et 8.2.9 : 18 failles corrigées, et un override qui annule le correctif

Par Maxime Bénard

15 min de lecture

Couverture d’article Mintfull : « 18 failles corrigées, un override peut garder la faille », versions PrestaShop 9.2.1 et 8.2.9, la commande grep qui trouve une redéfinition de validateOrder() dans override/classes/PaymentModule.php

PrestaShop a publié le lundi 9 novembre 2026 deux versions de maintenance, 9.2.1 et 8.2.9, accompagnées de 18 avis de sécurité le même jour. Pour mesurer l’ampleur : le dépôt du core en avait publié 9 sur les douze mois précédents. Aucun n’est classé « critical », 15 sont « high », et le plus haut score CVSS est de 8,8.

La réponse courte : une boutique en 8.x ou 9.x se met à jour cette semaine. Mais la mise à jour seule ne suffit pas toujours. Un override de PaymentModule peut garder une faille ouverte sur une boutique qui affiche 9.2.1, un correctif SVG livré dans un .htaccess ne fait rien sous nginx, et des connecteurs ERP multiboutiques vont recevoir des 403 qu’ils ne recevaient pas la veille. Nous avons lu les 18 avis et le diff du core (59 commits entre 9.2.0 et 9.2.1) : voici les failles classées par qui peut les exploiter, puis les contrôles à faire autour de la mise à jour.

15 failles touchent la 8 et la 9, trois ne visent que la 9

Quinze avis s’appliquent aux deux branches (>= 8.0.0, < 8.2.9 et >= 9.0.0, < 9.2.1). Trois ne concernent que PrestaShop 9 : deux sur l’Admin API, qui n’existe pas en 8, et une sur le mode debug. La bonne lecture n’est pas le score, c’est le niveau d’accès nécessaire :

Accès requisFailleAvisBranches
Aucun compteExécution de code par le cookie de session de l’installeur, si le dossier install est encore làGHSA-h2q4-7fp7-m5jp8, 9
Aucun compteInstalleur relançable sur une boutique installéeGHSA-8rxw-79mh-gmm5toutes avant 8.2.9, 9
Aucun compteBon de réduction à usage limité utilisé plusieurs fois (requêtes concurrentes)GHSA-r246-6gw4-pj3p8, 9
Aucun comptePlusieurs commandes créées depuis un seul panier à 0 €GHSA-2676-hc74-gg7v8, 9
Aucun compteTéléchargement du fichier de personnalisation d’un autre client, avec la référence de commandeGHSA-fg9q-mm4c-h5xc8, 9
Aucun compteJeton de réinitialisation du mot de passe prévisible si la secure_key du client est connueGHSA-g6p7-cx52-hcrj8, 9
Clé webserviceClé limitée à une boutique qui lit et modifie les clients de toutes les boutiquesGHSA-hjwq-7qvv-qxj4, GHSA-3f36-4qv4-vfgh8, 9
Clé webservicePièce jointe qui pointe hors du dossier download (lecture de parameters.php, suppression de fichiers)GHSA-wrmc-gmx5-9hhc8, 9
Client Admin APIClient qui s’accorde lui-même tous les scopes, jusqu’à l’envoi d’un moduleGHSA-p292-3rh4-vm4p9
Client Admin APIClient désactivé qui garde ses jetons et en obtient de nouveauxGHSA-6c2j-pgwm-95m99
Employé du back-officeSept failles : exécution de code par l’import de thème ou de module, les traductions, les traductions d’e-mails, le mode debug (9 seulement), XSS par SVG, modification de commandes avec le seul droit de créationGHSA-95w6, 7q68, fxq4, 5p6p, 95f5, 99cp, mgcf8, 9

Un détail des crédits mérite l’attention : plusieurs de ces failles ont été trouvées par des chercheurs qui ne se connaissaient pas. La lecture de fichiers par les pièces jointes a été signalée cinq fois, le jeton de mot de passe prévisible aussi. Une faille trouvée en parallèle par cinq personnes n’est pas une faille que l’on garde longtemps pour soi : maintenant que les avis décrivent le mécanisme, le délai pour mettre à jour se compte en jours.

Un dossier install oublié suffit pour exécuter du code sans compte

Les deux failles de l’installeur sont les plus graves en pratique, parce qu’elles ne demandent rien à l’attaquant. Avant 9.2.1, le cookie de session de l’installeur était protégé par une clé dérivée d’une valeur prévisible, puis désérialisé sans restreindre les classes. Et l’installeur acceptait de tourner sur une boutique déjà installée : lire les accès à la base, réécrire app/config/parameters.php (donc remplacer les clés secrètes), ou réinstaller par-dessus la base de production avec son propre compte administrateur.

Depuis 9.2.0, le dossier est supprimé automatiquement après l’installation. En 8.x, il fallait le faire à la main. Le correctif ajoute un garde-fou au début de install/index.php :

php
$installationState = new PrestaShopBundle\Install\InstallationState(_PS_CORE_DIR_ . '/app/config/parameters.php', PS_INSTALLATION_LOCK_FILE);
if ($installationState->isInstalled()) {
    http_response_code(409);
    exit('PrestaShop is already installed. To install it again, delete the app/config/parameters.php file first.');
}

Le contrôle se fait en une commande, depuis l’extérieur :

bash
curl -s -o /dev/null -w '%{http_code}\n' https://www.boutique.example/install/index.php

404 : le dossier n’est pas là, c’est l’état attendu. 409 : le dossier est là, mais la boutique est en 9.2.1 ou 8.2.9 et refuse de se réinstaller ; supprimez-le quand même. 200 : la boutique est exposée aujourd’hui. Pensez aussi aux variantes renommées (install_old, install-bak) que l’on trouve sur les serveurs qui ont connu plusieurs prestataires.

Sur une boutique conteneurisée, le piège est ailleurs. Si l’image Docker est construite à partir de l’archive de release, le dossier install fait partie de l’image : le supprimer dans le conteneur ne tient que jusqu’au prochain redémarrage du pod. Il se retire au build, dans le Dockerfile, comme le reste de ce qui ne doit pas tourner en production. Le principe est le même que pour le déploiement des modules sous Kubernetes : ce qui n’est pas dans l’image n’existe pas.

Si vous trouvez un 200 sur une boutique ouverte depuis des mois, mettre à jour ne suffit pas. Il faut chercher une trace d’intrusion, en commençant par les fichiers PHP là où il ne devrait pas y en avoir : notre méthode est dans l’article sur les webshells camouflés dans un module.

Un bon à usage unique servait autant de fois qu’on envoyait de requêtes simultanées

Deux avis décrivent la même famille de bug : une vérification, puis une écriture, sans rien entre les deux. Le stock d’un bon de réduction était vérifié à la validation du panier et décrémenté à la fin de la création de la commande. Des validations lancées en même temps passaient toutes la vérification, et chaque commande recevait la remise complète. Même chose pour un panier à 0 € : le contrôle « une commande existe déjà pour ce panier » n’était pas atomique, donc plusieurs commandes pouvaient naître d’un seul panier.

Le correctif est propre et mérite d’être lu. La quantité du bon est maintenant prise par un UPDATE conditionnel, dans le nouveau src/Adapter/CartRule/CartRuleQuantityReserver.php. C’est la base de données qui décide qui obtient la dernière unité :

sql
UPDATE `ps_cart_rule`
SET `quantity` = `quantity` - 1, `date_upd` = :dateUpd
WHERE `id_cart_rule` = :cartRuleId AND `quantity` > 0

Et la création de commande est sérialisée par panier avec un verrou nommé MySQL (src/Adapter/Order/OrderCreationLock.php, ici dans sa version 9.2.1), choisi plutôt qu’un verrou Symfony parce qu’il est partagé par tous les serveurs web qui parlent à la même base :

php
$result = $this->connection->fetchOne(
    'SELECT GET_LOCK(?, ?)',
    [$this->lockName($cartId), $this->waitTimeoutInSeconds],
    [ParameterType::STRING, ParameterType::INTEGER]
);

Le verrou est posé dans PaymentModuleCore::validateOrder(), qui délègue désormais tout son ancien contenu à une nouvelle méthode privée, validateOrderWithCartRuleReservations(). C’est là que se cache le piège suivant.

Un override de PaymentModule::validateOrder() garde la faille après la mise à jour

Beaucoup de boutiques qui ont quelques années ont un fichier override/classes/PaymentModule.php. La raison est presque toujours la même : modifier un détail de la création de commande (une variable dans l’e-mail de confirmation, un statut, un champ supplémentaire), en copiant toute la méthode validateOrder() du core pour en changer trois lignes.

Avec 9.2.1 et 8.2.9, cette copie remplace la nouvelle méthode du core, qui contient le verrou et la réservation du bon. L’ancien code recopié s’exécute à la place, sans verrou ni réservation, et il ne peut même pas appeler la nouvelle méthode puisqu’elle est privée. Le back-office affiche la bonne version, le changelog est appliqué, et la boutique reste vulnérable aux deux failles de concurrence. Aucune erreur, aucun log : c’est exactement le genre de panne muette qu’un audit de version ne voit pas.

Le même raisonnement s’applique aux modules de paiement qui redéfinissent validateOrder() dans leur propre classe sans appeler parent::validateOrder(). Deux commandes, à la racine de la boutique, suffisent à faire l’inventaire :

bash
# Override du core
grep -n "function validateOrder" override/classes/PaymentModule.php 2>/dev/null

# Modules qui redéfinissent validateOrder() sans appeler le parent
grep -rl --include=*.php "function validateOrder" modules/ \
  | xargs -r grep -L "parent::validateOrder"

Si la première commande renvoie une ligne, l’override doit être réécrit avant ou pendant la mise à jour : soit en s’appuyant sur un hook (actionValidateOrder, actionValidateOrderAfter, sendMailAlterTemplateVars pour les e-mails), soit en appelant parent::validateOrder() et en agissant après. Pour la seconde, chaque fichier listé est à lire : certains délèguent sous un autre nom, d’autres réécrivent vraiment la création de commande. Pour un module tiers, c’est à son éditeur de publier une version compatible ; en attendant, vous savez que le correctif ne s’applique pas à ce moyen de paiement.

Ne cherchez pas la preuve dans les logs : le core ne journalise le verrou que lorsqu’une requête l’attend plus de 10 secondes, ce qui n’arrive presque jamais. Une requête concurrente qui obtient le verrou après la première tombe simplement sur la vérification « commande déjà créée », sans trace particulière. Le seul contrôle fiable est la lecture du code qui s’exécute vraiment, override compris.

Le webservice multiboutique répond 403, code 132, à des appels qui passaient la veille

Deux avis corrigent le contrôle des boutiques dans le webservice. Une clé associée à une seule boutique pouvait lire et modifier les clients de toutes les boutiques, soit en demandant id_shop=all ou un groupe de boutiques, soit tout simplement parce que le core vérifiait la boutique nommée dans la requête et jamais celle de l’enregistrement chargé. Hash du mot de passe et secure_key compris, ce qui permettait de prendre le contrôle d’un compte.

Le correctif est strict, et c’est une bonne chose. Mais il change le comportement de classes/webservice/WebserviceRequest.php (334 lignes ajoutées) pour toutes les intégrations qui vivaient sur la tolérance d’avant. Désormais, une requête id_shop=all n’est acceptée que si la clé est associée à toutes les boutiques actives :

php
$allShopIds = $this->normalizeShopIds(Shop::getShops(true, null, true));
if (empty($allShopIds) || count(array_intersect($allShopIds, $allowedShopIds)) !== count($allShopIds)) {
    $this->setError(403, 'No permission for this key on this shop', 132);

    return false;
}

Deux cas vont casser au lendemain de la mise à jour. Le connecteur ERP qui synchronise en id_shop=all avec une clé créée quand la boutique n’avait qu’un site, et dont personne n’a coché les boutiques ajoutées depuis. Et le connecteur qui lit un client ou une commande par son identifiant sans préciser id_shop : la requête est rattachée à la boutique de l’URL appelée, et un enregistrement d’une autre boutique (hors partage des clients ou des commandes entre boutiques d’un même groupe) répond maintenant 404 « does not exists on this shop », code 131. Dans les deux cas, l’erreur est la bonne : la clé n’avait jamais été autorisée à faire ce qu’elle faisait.

L’inventaire des clés, de leurs boutiques et de leurs droits sensibles tient en une requête :

sql
SELECT a.id_webservice_account, a.description, a.active,
       GROUP_CONCAT(DISTINCT s.id_shop ORDER BY s.id_shop) AS boutiques,
       GROUP_CONCAT(DISTINCT CONCAT(p.resource, ':', p.method)
                    ORDER BY p.resource SEPARATOR ' ') AS droits_sensibles
FROM ps_webservice_account a
LEFT JOIN ps_webservice_account_shop s ON s.id_webservice_account = a.id_webservice_account
LEFT JOIN ps_webservice_permission p ON p.id_webservice_account = a.id_webservice_account
       AND p.resource IN ('customers', 'attachments')
GROUP BY a.id_webservice_account;

Comparez la colonne boutiques avec ce que chaque intégration appelle réellement, avant de mettre à jour : une clé qui doit servir plusieurs boutiques s’associe à chacune dans Paramètres avancés > Webservice. Une clé avec attachments:PUT ou attachments:POST est aussi celle qui permettait de lire parameters.php par une pièce jointe : à retirer si l’intégration n’en a pas besoin. Une clé inactive dont personne ne connaît l’usage se supprime. Le contexte boutique est le piège récurrent du multiboutique, nous l’avions déjà rencontré avec Module::enable() qui n’active un module que sur une boutique, et le webservice 9.2 nous avait déjà réservé un champ client custom disparu.

Désactiver un client de l’Admin API ne coupait pas ses jetons

Sur PrestaShop 9, l’Admin API ignorait l’état « activé » d’un client au moment d’émettre un jeton, et les jetons émis n’étaient pas révocables. Désactiver le client d’un prestataire parti ou d’un outil abandonné ne coupait donc rien. Pire, un client qui avait le scope api_client_write pouvait s’accorder n’importe quel autre scope, dont l’envoi de modules, c’est-à-dire l’exécution de code sur le serveur.

Avant de mettre à jour, listez les clients et repérez ceux qui détiennent ce scope :

sql
SELECT id_api_client, client_name, enabled, lifetime, scopes,
       JSON_CONTAINS(scopes, '"api_client_write"') AS peut_gerer_les_clients
FROM ps_api_client
ORDER BY peut_gerer_les_clients DESC, client_name;

Un client désactivé avant la mise à jour doit être supprimé, ou son secret régénéré : c’est la seule action qui coupait vraiment l’accès sur 9.0 à 9.2.0. Le scope api_client_write ne se donne qu’à un client à qui l’on confierait tous les autres. Et un lifetime de 3600 secondes (la valeur par défaut) reste raisonnable ; les durées de vie allongées « pour que l’outil arrête de se déconnecter » sont à revoir.

Le correctif SVG est un .htaccess, et nginx ne lit pas les .htaccess

La faille XSS par SVG (GHSA-99cp-c6mm-p7h5) permettait à un employé ayant accès au gestionnaire de fichiers, par exemple depuis la fiche produit, ou à l’envoi du logo, de déposer un SVG contenant du JavaScript. Servi depuis le domaine de la boutique, ce script s’exécutait dans la session du premier administrateur qui ouvrait le fichier.

Le correctif tient en deux parties. Les nouveaux envois passent par Validate::isCleanSVG(), qui parse le document et refuse scripts, gestionnaires d’événements et entités douteuses. Les SVG déjà présents sont neutralisés par des en-têtes HTTP, ajoutés dans img/.htaccess :

apache
<IfModule mod_headers.c>
    <FilesMatch "(?i)\.svg$">
        Header set Content-Security-Policy "script-src 'none'"
        Header set Content-Disposition "attachment"
        Header set X-Content-Type-Options "nosniff"
    </FilesMatch>
</IfModule>

Sur une boutique servie par nginx et PHP-FPM, ce fichier n’est jamais lu. Les SVG envoyés avant la mise à jour restent servis tels quels, et la protection des fichiers existants n’existe pas. Il faut reporter la règle dans la configuration nginx :

nginx
location ~* ^/img/.+\.svg$ {
    add_header Content-Security-Policy "script-src 'none'" always;
    add_header Content-Disposition "attachment" always;
    add_header X-Content-Type-Options "nosniff" always;
}

Attention au comportement de add_header : dès qu’un bloc location en déclare un, il n’hérite plus de ceux du niveau server. Si vos en-têtes de sécurité (HSTS, X-Frame-Options) sont posés au niveau serveur, répétez-les dans ce bloc ou passez par un include commun. Le Content-Disposition: attachment ne gêne pas l’affichage d’un logo SVG dans une balise <img> : il ne s’applique qu’à l’ouverture directe du fichier.

Pour savoir si un SVG piégé est déjà là :

bash
find img -iname '*.svg' -exec grep -liE '<script|on[a-z]+=|javascript:|<foreignObject' {} +

La commande est volontairement large et sortira des faux positifs sur des SVG exportés par des outils de dessin ; chaque fichier listé est à ouvrir dans un éditeur de texte, pas dans un navigateur.

Sept failles exigent un compte employé, et le droit « Performances » suffisait pour exécuter du code

Les avis restants demandent un accès au back-office. Ils pèsent moins lourd qu’une faille anonyme, mais cinq d’entre eux mènent au même endroit : un profil employé limité pouvait obtenir l’exécution de code sur le serveur, donc tout le reste. Le cas le plus parlant ne concerne que la 9 : le nom et la valeur du cookie de debug, saisis sur la page Performances, étaient écrits sans échappement dans un fichier PHP exécuté à chaque requête, front-office compris. Un profil qui n’a que le droit Performances, justement donné à un prestataire chargé de la vitesse du site, pouvait donc injecter du PHP.

Les contournements publiés se résument à une règle : jusqu’à la mise à jour, les droits Import, Traductions, Thèmes, Modules, Performances, gestionnaire de fichiers et logo ne se donnent qu’à des personnes à qui l’on confierait l’accès au serveur. Le moment est bon pour relire les profils dans Paramètres avancés > Équipe > Profils, et supprimer les comptes employés des prestataires qui ne travaillent plus sur la boutique.

Pas de 1.7.8.12 : une boutique en 1.7 garde ces failles

Quatorze des quinze avis communs aux deux branches ne remontent pas plus bas que 8.0.0. Le quinzième, l’installeur relançable, vise toutes les versions antérieures à 8.2.9, 1.7 comprise, et aucune version 1.7 n’a été publiée le 9 novembre : la branche n’est plus maintenue. Rien ne dit que ces failles n’y existent pas : le code du webservice, de l’installeur ou de la réinitialisation du mot de passe y est largement le même. Une boutique en 1.7.8 ne peut compter que sur les contournements : supprimer le dossier install, couper le webservice s’il ne sert pas, restreindre les clés et les profils employés, et ajouter les en-têtes SVG au serveur web. Ce n’est pas une protection, c’est une réduction de surface. Le seul correctif est une migration, dont nous avons détaillé la méthode dans la migration de PrestaShop 1.7 vers 8 en anticipant la 9.

Six contrôles avant de passer en 9.2.1 ou 8.2.9

  • ☐ curl sur /install/index.php et ses variantes renommées : 404 attendu. Sur une image Docker, le dossier est retiré au build.
  • ☐ override/classes/PaymentModule.php sans redéfinition de validateOrder(), et modules de paiement qui appellent parent::validateOrder().
  • ☐ Clés webservice : boutiques associées conformes aux appels des intégrations (sinon 403 code 132), droits attachments retirés là où ils ne servent pas.
  • ☐ PrestaShop 9 : clients de l’Admin API désactivés supprimés ou secret régénéré, scope api_client_write limité.
  • ☐ nginx : en-têtes SVG reportés dans la configuration, en vérifiant l’héritage des add_header.
  • ☐ Profils employés relus, comptes des anciens prestataires supprimés.

La mise à jour elle-même est une version de maintenance : pas de migration de schéma annoncée dans les notes de version, Symfony passe en 6.4.50 côté 9.2.1, psgdpr en 1.4.4 et ps_emailsubscription en 3.0.1 sur les deux branches. Elle se teste comme les autres sur une préproduction, avec un passage de commande complet, un code promo et un appel de chaque intégration webservice. Si vous êtes encore en 9.0 ou 9.1, le passage en 9.2 apporte aussi ses propres changements, que nous avions recensés dans PrestaShop 9.2 vu depuis nos projets.

Partager cet article
Articles

Des articles pour faire pousser vos idées

D'autres retours d'expérience et méthodes à mettre en pratique sur votre site.

Bon, on en discute ?

Demandez votre diagnostic de performance gratuit : un échange de 30 minutes, vos Core Web Vitals relevés sur mobile et une synthèse écrite de ce qui mérite d’être corrigé en premier.

Discuter de mes besoins