WordPress
18 décembre 2024

Un module sur WordPress et PrestaShop : ce qui se partage, et trois croyances de sécurité qui sont fausses

Les meilleures pratiques pour développer des modules WordPress et PrestaShop

Les listes de bonnes pratiques pour développer un module existent déjà, et les documentations officielles de WordPress et de PrestaShop les font mieux que n’importe quel article d’agence. « Validez les entrées », « testez sur une préproduction », « documentez » : personne n’a besoin de nous pour l’apprendre.

Ce qui n’est écrit nulle part, en revanche, c’est ce qui se passe quand on doit livrer la même fonctionnalité sur les deux CMS à la fois. C’est le cas du store locator que nous avons développé pour Opal, présent sur des sites du groupe tournant les uns sous PrestaShop, les autres sous WordPress. Cette contrainte oblige à trancher ce qui se partage et ce qui ne se partagera jamais, et la réponse n’est pas celle qu’on croit.

Au passage, trois affirmations de sécurité qu’on répète des deux côtés, y compris dans la version précédente de cet article, ne survivent pas à une lecture du code source.

Au programme :

  • Le seul découpage qui tient quand on cible deux CMS
  • Pourquoi les hooks des deux plateformes n’ont de commun que le nom
  • Un nonce WordPress n’est ni unique ni éphémère
  • pSQL() n’est pas une requête préparée, et il abîme vos données au passage
  • Ce que ce type de projet coûte, et que personne ne budgète

Le seul découpage qui tient

La tentation naturelle est de partager le maximum de code entre les deux versions. C’est une erreur, parce que la frontière ne passe pas là où on l’imagine. Ce qui se partage, ce n’est pas « le module moins l’affichage », c’est uniquement ce qui ne connaît ni l’un ni l’autre CMS.

Concrètement, le calcul des distances, le filtrage par marque, la normalisation des adresses et le dialogue avec l’ERP sont du PHP ordinaire, qui n’a besoin d’aucune fonction WordPress ni d’aucune classe PrestaShop. Cette partie vit dans un paquet Composer autonome, testable sans installer quoi que ce soit.

Tout le reste est spécifique à l’hôte, et il faut l’accepter dès le départ plutôt que de chercher à l’abstraire :

// Le contrat, dans le paquet commun : il ne connaît aucun CMS
interface DepotDeRevendeurs
{
    /** @return Revendeur[] */
    public function autourDe(Coordonnees $point, int $rayonKm, ?string $marque): array;
}

interface ReglagesLocalisateur
{
    public function rayonParDefaut(): int;
    public function cleApi(): string;
}

Et deux implémentations qui, elles, ne partagent rien :

// Côté PrestaShop : ObjectModel, Db, Configuration
final class ReglagesPrestaShop implements ReglagesLocalisateur
{
    public function rayonParDefaut(): int
    {
        return (int) Configuration::get('OPAL_RAYON', null, null, (int) Context::getContext()->shop->id);
    }
}

// Côté WordPress : options API
final class ReglagesWordPress implements ReglagesLocalisateur
{
    public function rayonParDefaut(): int
    {
        return (int) get_option('opal_rayon', 25);
    }
}

Notez le paramètre id_shop côté PrestaShop, qui n’a aucun équivalent côté WordPress. C’est représentatif de tout le reste : les deux plateformes ne divergent pas par la syntaxe mais par les concepts. Chercher une abstraction commune au stockage de configuration produit une couche qui ment sur les deux.

La règle qu’on applique : si le code prononce le mot « shop », « post », « hook » ou « admin », il est spécifique à l’hôte. Le reste est partageable, et c’est en général bien moins que ce qu’on espérait.

Les hooks n’ont de commun que le nom

C’est le point que les comparatifs traitent en une phrase, alors qu’il détermine le comportement de votre module à l’installation et à la désinstallation.

Sur WordPress, un hook est un enregistrement en mémoire, refait à chaque requête. Votre plugin appelle add_action() au chargement, et si le fichier n’est pas chargé, le hook n’existe pas. Il n’y a aucun état persistant.

Sur PrestaShop, c’est l’inverse : le lien entre un module et un hook vit en base de données. Le module appelle registerHook() pendant son install(), la relation est écrite dans ps_hook_module, et c’est le noyau qui va la chercher au moment d’exécuter le hook. On l’a vérifié plus tôt dans un autre contexte, la résolution passe littéralement par la base :

// classes/tax/TaxManagerFactory.php (PrestaShop 9.0.0)
$modules_infos = Hook::getModulesFromHook(Hook::getIdByName('taxManager'));

Trois conséquences pratiques qui n’ont aucun équivalent WordPress. Ajouter un hook dans une nouvelle version de votre module ne suffit pas : il faut un script de mise à jour qui appelle registerHook(), sinon le code existe et ne s’exécute jamais. Un module désactivé garde ses enregistrements. Et deux modules branchés sur le même hook s’exécutent dans un ordre défini en base, modifiable depuis le back-office, ce qui veut dire que l’ordre d’exécution n’est pas dans votre dépôt Git.

Côté WordPress, l’ordre se règle avec le paramètre de priorité de add_action(), donc dans le code, donc versionné. La même intention s’écrit très différemment :

// WordPress : déclaratif, refait à chaque requête, priorité dans le code
add_action('woocommerce_before_main_content', [$this, 'afficherLocalisateur'], 20);

// PrestaShop : enregistrement persistant à l'installation...
public function install(): bool
{
    return parent::install()
        && $this->registerHook('displayHome');
}

// ...puis une méthode nommée d'après le hook, appelée par le noyau
public function hookDisplayHome(array $params): string
{
    return $this->fetch('module:opal_locator/views/templates/hook/home.tpl');
}

Deux modèles cohérents, mais opposés, et la migration mentale de l’un vers l’autre est là où les développeurs se trompent.

Trois croyances de sécurité, fausses des deux côtés

Un nonce WordPress n’est ni unique ni éphémère

Le nom promet un « number used once ». Le code dit autre chose. Voici wp_verify_nonce(), commentaires d’origine compris :

$i = wp_nonce_tick( $action );

// Nonce generated 0-12 hours ago.
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
    return 1;
}

// Nonce generated 12-24 hours ago.
$expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
    return 2;
}

Et wp_nonce_tick() découpe le temps en tranches de douze heures, puisque $nonce_life vaut DAY_IN_SECONDS et que la fonction renvoie ceil(time() / ($nonce_life / 2)).

Un nonce WordPress est donc valide jusqu’à vingt-quatre heures et réutilisable autant de fois qu’on veut dans cette fenêtre. C’est un jeton lié à un utilisateur, à une session et à une action, ce qui suffit à contrer une requête forgée depuis un autre site, mais ne protège en rien contre le rejeu. Si votre action ne doit pas pouvoir être exécutée deux fois, un nonce ne vous couvre pas, il faut une garde d’idempotence côté métier.

Détail que presque personne n’exploite : la fonction renvoie 1 ou 2 selon la fenêtre d’origine, jamais true. Un 2 signifie que le jeton a plus de douze heures, ce qui est une information utilisable pour redemander une confirmation sur une action sensible.

pSQL() n’est pas une requête préparée

Côté PrestaShop, la croyance est que pSQL() protège des injections. Il délègue à Db::escape(), dont voici le corps complet :

// classes/db/Db.php (PrestaShop 9.0.0)
public function escape($string, $html_ok = false, $bq_sql = false)
{
    if (!is_numeric($string)) {
        $string = $this->_escape($string);

        if (!$html_ok) {
            $string = strip_tags(Tools::nl2br($string));
        }

        if ($bq_sql === true) {
            $string = str_replace('`', '\`', $string);
        }
    }

    return $string;
}

Deux choses sautent aux yeux.

Il échappe les quotes, il ne les ajoute pas. Une valeur passée par pSQL() mais insérée dans un contexte non entouré de quotes n’est pas protégée. La règle qui tient est donc la règle historique de PrestaShop : (int) pour les entiers, pSQL() à l’intérieur de quotes pour les chaînes. Jamais l’un à la place de l’autre.

Et par défaut, il abîme vos données. Sans $html_ok = true, la valeur passe par strip_tags(Tools::nl2br($string)). Autrement dit, faire transiter une description produit par pSQL() sans le second paramètre convertit les retours à la ligne en <br /> puis retire toutes les balises, y compris ceux qu’on vient de créer. Le texte est enregistré, il est simplement mutilé, et rien ne le signale.

Notez enfin que si la valeur est numérique, la fonction la renvoie telle quelle sans aucun traitement. C’est cohérent, mais ça explique pourquoi tant de code semble fonctionner en s’appuyant sur pSQL() seul : sur des identifiants numériques, il ne fait littéralement rien.

Nettoyer une entrée n’est pas échapper une sortie

La troisième confusion est commune aux deux plateformes, et c’est la plus répandue. La version précédente de cet article citait sanitize_text_field() dans un paragraphe consacré à la prévention des failles XSS. Ce n’est pas son rôle. Sa documentation dans le cœur de WordPress liste ce qu’elle fait : elle retire toutes les balises, convertit les < isolés en entités, supprime les sauts de ligne et les caractères encodés en pourcentage.

C’est une fonction d’entrée. Elle décide de ce qu’on accepte de stocker. La protection contre le XSS se joue à la sortie, au moment du rendu, avec esc_html() ou esc_attr() selon le contexte, et l’équivalent côté PrestaShop est l’échappement du moteur de gabarits.

La règle des deux CMS est la même et tient en une phrase : on nettoie en entrée, on échappe en sortie, et l’un ne dispense jamais de l’autre. Une donnée propre au moment du stockage peut parfaitement devenir dangereuse dans un contexte de sortie différent, par exemple à l’intérieur d’un attribut HTML ou d’un bloc de script.

Ce que ça coûte, et que personne ne budgète

Livrer une même fonctionnalité sur deux CMS ne coûte pas deux fois le prix, mais il ne faut pas croire que le coût est proche de une fois non plus. Voici les trois postes que les estimations oublient systématiquement.

L’interface d’administration s’écrit deux fois. C’est la partie la plus visible et la moins partageable. Une page de réglages PrestaShop et une page de réglages WordPress n’ont aucun composant en commun, et ce sont pourtant elles qui déterminent la perception du produit par le client.

Les deux écosystèmes n’évoluent pas au même rythme. Une version majeure d’un CMS peut demander une reprise d’un côté seulement, ce qui casse l’illusion de la base de code unique. Il faut donc un versionnage qui permette aux deux distributions de diverger temporairement sans bloquer la partie commune.

Le support double, lui, tout de suite. Le même symptôme rapporté par un client peut avoir deux causes selon l’hôte, et il faut deux environnements de reproduction. C’est le poste qui surprend le plus après la livraison.

Ce qu’on y gagne, en revanche, est réel : la logique métier est testée une seule fois, elle est corrigée une seule fois, et les deux plateformes reçoivent le correctif ensemble. Sur une fonctionnalité qui a une vraie valeur métier, comme une synchronisation avec un ERP, c’est ce qui fait la différence entre deux produits qui dérivent et un seul produit servi deux fois.

Notre position

Le double ciblage se justifie quand la valeur est dans la logique métier et que l’affichage est secondaire. Un connecteur, un calculateur, une synchronisation : oui. Une fonctionnalité dont l’essentiel est l’interface, non, on écrit deux produits et on assume.

Et sur la sécurité, le fond de l’affaire est que les deux CMS fournissent des fonctions dont le nom promet plus que ce qu’elles font. pSQL évoque une requête préparée sans en être une, nonce évoque l’usage unique sans le garantir, sanitize évoque une protection générale alors qu’il ne traite que l’entrée. Lire le corps de ces trois fonctions prend un quart d’heure, et c’est probablement le meilleur quart d’heure qu’un développeur de modules puisse investir.

Questions fréquentes

Peut-on vraiment partager du code entre un module PrestaShop et un plugin WordPress ?
Oui, mais uniquement la part qui ne connaît aucun des deux. En pratique, la logique métier dans un paquet Composer autonome, avec des interfaces pour tout ce qui touche au stockage, à la configuration et au rendu. Dès que le code prononce « shop », « post » ou « hook », il est spécifique à l’hôte.

Un nonce WordPress protège-t-il du rejeu d’une action ?
Non. Il reste valide jusqu’à vingt-quatre heures et il est réutilisable dans cette fenêtre, ce que le code de wp_verify_nonce() indique explicitement en commentaire. Il protège contre une requête forgée depuis un autre site, pas contre une double soumission. Une action non idempotente demande une garde supplémentaire.

pSQL() suffit-il à éviter une injection SQL ?
Non. Il échappe les quotes mais ne les ajoute pas, donc une valeur insérée hors quotes reste exposée. Et il renvoie les valeurs numériques sans traitement. La règle est le transtypage (int) pour les entiers et pSQL() entre quotes pour les chaînes.

Pourquoi ma description produit perd-elle sa mise en forme ?
Parce qu’elle est passée par pSQL() sans le second paramètre. La fonction applique alors strip_tags(Tools::nl2br(...)), ce qui retire toutes les balises. Utilisez pSQL($valeur, true) pour du contenu HTML légitime.

Pourquoi mon nouveau hook ne s’exécute-t-il pas après une mise à jour de module ?
Parce que sur PrestaShop le lien module-hook vit en base. Ajouter une méthode hookXxx() ne suffit pas, il faut un script de mise à jour qui appelle registerHook(). Sur WordPress, la question ne se pose pas puisque l’enregistrement est refait à chaque requête.

Faut-il un module ou un développement dans le thème ?
Un module, dès que la fonctionnalité doit survivre à un changement de thème ou être réutilisée. Le thème n’est pas un endroit où l’on met de la logique métier, sur aucune des deux plateformes.

Vous avez une fonctionnalité métier à livrer sur plusieurs plateformes et vous vous demandez ce qui peut être mutualisé ? La réponse dépend surtout du poids de l’interface dans votre projet. Décrivez-nous le besoin, on vous dira franchement si le double ciblage vaut le coup.

Nous vous recommandons aussi