Aller au contenu principal

Twig 3.28 : le sandbox arrête de tout emballer, les macros redeviennent dynamiques.

Découvrez les nouveautés de Twig 3.28 : le retour des macros dynamiques grâce à l'opérateur point et un sandbox plus performant. Auditez vos extensions Twig.

MAJ 6 min de lecture
Sommaire · 5

Twig 3.28.0 est sorti le 3 juillet. Si vous rendez du contenu qui ne vient pas de vous (templates utilisateur, blocs éditoriaux, emails personnalisables), cette version vous concerne directement. Deux chantiers : le retour des appels de macro dynamiques, orphelins depuis la dépréciation de attribute(), et un sandbox qui génère moins de code à l'exécution. Promesse du billet : à la fin, vous saurez exactement quoi auditer dans vos extensions Twig maison avant de monter de version.

Le retour des macros dynamiques

Petit rappel du trou dans la raquette. La fonction attribute() était dépréciée au profit de l'opérateur point, sauf que l'opérateur point ne savait pas cibler une macro. Appeler une macro dont le nom n'est connu qu'à l'exécution obligeait donc à empiler des {% if %} :

Pour aller plus loin

Twig
{% if type == 'text' %}
    {{ forms.input_text(field) }}
{% elseif type == 'email' %}
    {{ forms.input_email(field) }}
{% endif %}

Avec Twig 3.28, l'opérateur point accepte une expression parenthésée, évaluée à l'exécution pour obtenir le nom de la macro :

Twig
{{ forms.('input_' ~ type)(field) }}

Cinq branches de template deviennent une ligne. Et on referme proprement la parenthèse ouverte par la dépréciation de attribute() : il n'y avait pas de remplaçant pour ce cas précis.

Le cas d'usage réel, c'est la bibliothèque de rendu de champs. Un thème de formulaire maison où chaque type de champ (texte, email, choix, date) a sa macro dédiée, et où le template appelant itère sur une définition de formulaire. Même logique pour un design system en Twig pur : une macro par variante de composant, un point d'entrée qui route selon la donnée. Jusqu'ici, ce routage s'écrivait à coups de {% if %}, ou se contournait avec des include() de templates aux noms dynamiques. Désormais, la donnée pilote l'appel.

Pour reprendre les bases

À la compilation, l'expression parenthésée est recopiée telle quelle dans le code PHP généré, puis évaluée à chaque rendu. Twig préfixe le résultat par macro_, le préfixe des méthodes PHP générées pour chaque macro, et vérifie que la méthode existe dans le template importé. Un nom forgé reste donc cantonné aux macros : les autres méthodes de la classe compilée sont hors d'atteinte. Le CHANGELOG résume la syntaxe en une ligne : macros.(name)(args).

Un sandbox qui arrête de tout emballer

Le sandbox de Twig, c'est la couche de sécurité qui permet de rendre un template non fiable. Il n'autorise qu'une liste de filtres, fonctions et tags. Jusqu'ici, il payait deux taxes à l'exécution.

Première taxe : chaque argument de chaque appel était enveloppé dans une vérification __toString, pour empêcher un objet malveillant de s'exécuter à la conversion en chaîne. Twig 3.28 ne wrappe plus que les arguments dont le type PHP peut réellement coércer vers une chaîne. Un paramètre typé int n'est plus touché. Moins de code généré dans les templates compilés, rendu sandboxé plus rapide.

Deuxième taxe : chaque nom de filtre ou de fonction était enregistré puis vérifié contre la security policy, même pour des callables parfaitement inoffensifs. Les filtres, fonctions et tests acceptent maintenant une option always_allowed_in_sandbox, et les token parsers peuvent implémenter isAlwaysAllowedInSandbox(). Quand l'option est posée, le sandbox ne consigne même pas le nom : pas d'entrée d'allow-list à maintenir, pas de coût à l'exécution. Les tests natifs sûrs (defined, empty, etc.) sont déjà flaggés ainsi, ce qui explique qu'ils continuent de fonctionner sans configuration.

PHP
new TwigFilter('excerpt', $this->excerpt(...), [
    'always_allowed_in_sandbox' => true,
]);

Monter un sandbox minimal

Puisqu'on parle du sandbox, posons la mise en place complète. Deux objets suffisent : une SecurityPolicy qui décrit ce qui est autorisé, et la SandboxExtension qui l'applique.

PHP
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityError;
use Twig\Sandbox\SecurityPolicy;

$policy = new SecurityPolicy(
    allowedTags: ['if', 'for'],
    allowedFilters: ['escape', 'upper', 'length'],
    allowedMethods: [Article::class => ['getTitle', 'getBody']],
    allowedProperties: [],
    allowedFunctions: ['range'],
);

$twig = new Environment(new ArrayLoader([
    'profil' => $sourceUtilisateur, // template non fiable
]));
$twig->addExtension(new SandboxExtension($policy, sandboxed: true));

try {
    echo $twig->render('profil', ['article' => $article]);
} catch (SecurityError $e) {
    // tag, filtre, fonction ou méthode hors allow-list
}

Tout ce qui sort des listes lève une SecurityError au rendu : tag interdit, filtre inconnu de la policy, méthode non déclarée sur la classe. Les méthodes s'autorisent classe par classe, et la comparaison des noms ignore la casse. Depuis 3.28, le constructeur accepte un sixième argument allowedTests : les tests ont leur propre allow-list, celle qu'on évoquait plus haut.

Le deuxième argument sandboxed: true active le mode pour tous les templates de l'environnement. Dans une vraie application, on le laisse plutôt à false et on cible le seul fragment non fiable : soit avec le tag {% sandbox %} autour d'un {% include %}, soit avec l'option sandboxed de la fonction include(). Le layout reste libre, le contenu utilisateur est bridé.

Ce que ça implique si vous maintenez une extension

C'est ici que la doc s'arrête et que le travail commence. always_allowed_in_sandbox est un contrat fort : le callable échappe à la policy partout, pour toujours, dans tous les sandboxes de l'application. Avant de poser l'option sur un filtre, trois questions :

  • Le callable est-il pur ? Un filtre de troncature de chaîne, oui. Un filtre qui touche au container, aux fichiers ou à une entité, jamais.
  • Peut-il exfiltrer de l'état ? Un filtre qui lit une config applicative pour formater sa sortie expose cette config à tout template sandboxé.
  • Sera-t-il toujours pur dans deux ans ? L'option ne se voit pas à l'appel. Le jour où quelqu'un injecte un service dans l'extension, personne ne pensera à la retirer.

La bonne heuristique : réserver l'option aux filtres de présentation pure, et laisser tout le reste passer par l'allow-list explicite de la policy. La policy se lit, se revoit en PR, se greppe. L'option, non.

Des colonnes dans les erreurs de syntaxe

Même release, ajout discret : les erreurs de syntaxe portent maintenant un numéro de colonne, exposé via Error::getTemplateColumn() en plus de la ligne. Le lexer trace aussi l'offset de chaque token dans la source, exposé via Token::getOffset(), de quoi nourrir des outillages plus précis (linters, surlignage en éditeur). Sur une ligne dense, la ligne seule ne disait pas où regarder ; la colonne pointe l'endroit exact.

Reste une question d'architecture, et elle mériterait son propre billet. Votre contenu utilisateur, il passe par un sandbox Twig, ou par un sanitizer HTML en amont ? Les deux ne protègent pas contre la même chose. Ce blog a choisi le sanitizer, et la comparaison honnête des deux approches est encore à écrire.

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Vous aimerez aussi

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.