Blog / Symfony

Symfony 8.2 : trans_html, ou comment sortir du |trans|raw

Un |trans|raw dans Twig laisse passer le HTML des paramètres. Symfony 8.2 ajoute trans_html ; d’ici là, voici comment trouver et neutraliser ces appels.

Par Romain Barberi 7 min de lecture 5 sources

{{ 'welcome'|trans({'%name%': app.user.username})|raw }} : cette ligne, ou sa cousine, existe dans presque toutes les applications Symfony qui ont quelques années. Quelqu’un voulait un lien au milieu d’une phrase traduite, Twig échappait le <a>, et |raw a réglé le problème en deux secondes. Il en a aussi créé un autre, que personne ne voit en recette : le nom d’utilisateur passé en paramètre n’est plus échappé du tout. Le 19 octobre, le blog de Symfony a présenté le filtre trans_html, attendu dans Symfony 8.2 en novembre, qui rend enfin ce besoin possible sans |raw. Je pense que la bonne nouvelle n’est pas le filtre lui-même, que la plupart des projets legacy n’auront pas avant longtemps : c’est qu’il donne un nom et une règle à un trou qu’on peut boucher dès aujourd’hui, sur n’importe quelle version.

Le problème, reproduit en quelques lignes

La documentation de Symfony le dit en toutes lettres : un message traduit par le filtre |trans est échappé par défaut, et la balise {% trans %} ne l’est pas. Ce que la documentation dit moins clairement, c’est ce qu’il advient des paramètres. J’ai monté le cas le plus courant dans un conteneur, avec Twig 3.30.0, symfony/translation 7.4.17 et symfony/twig-bridge 7.4.19 sur PHP 8.3 : un message qui contient un lien, un paramètre %name%, et un nom d’utilisateur piégé.

$translator->addResource('array', [
    'welcome' => 'Bonjour %name%, <a href="/profil">voir votre profil</a>.',
], 'fr');

$twig = new Twig\Environment(new Twig\Loader\ArrayLoader([
    'filtre_raw' => "{{ 'welcome'|trans({'%name%': name})|raw }}",
    'filtre'     => "{{ 'welcome'|trans({'%name%': name}) }}",
    'balise'     => "{% trans with {'%name%': name} %}welcome{% endtrans %}",
]), ['autoescape' => 'html']);

$name = '<img src=x onerror=alert(1)>';

Le script a été exécuté tel quel, et voici sa sortie :

filtre_raw : Bonjour <img src=x onerror=alert(1)>, <a href="/profil">voir votre profil</a>.
filtre     : Bonjour &lt;img src=x onerror=alert(1)&gt;, &lt;a href=&quot;/profil&quot;&gt;voir votre profil&lt;/a&gt;.
balise     : Bonjour <img src=x onerror=alert(1)>, <a href="/profil">voir votre profil</a>.

Le filtre seul est sûr mais inutilisable : le lien s’affiche en texte. Les deux autres formes affichent le lien, et injectent la balise <img> telle quelle. Le paramètre est substitué dans la chaîne traduite avant que |raw ne désactive l’échappement de l’ensemble, si bien que Twig ne fait plus la différence entre le HTML voulu par le traducteur et celui fourni par l’utilisateur. Avec un pseudo, un nom de société ou un titre d’annonce saisi par un client, c’est une XSS stockée.

Ce que trans_html change dans Symfony 8.2

Le besoin n’est pas neuf. L’issue #59698, ouverte en février 2025 par Hugo Alliaume, demandait d’interpoler des composants HTML dans les traductions, et un mainteneur y signalait déjà le risque de XSS lié à |raw. La PR #66690, ouverte le 7 octobre 2026 par le même auteur, a été mergée le 13 octobre sur la branche 8.2.

Le principe : la traduction ne contient plus de vraies balises, seulement des pseudo-balises sans attributs. C’est le template qui dit à quoi elles correspondent, sous forme de données.

# translations/messages+intl-icu.fr.yaml
auth_prompt: 'Bonjour {name} ! Merci de vous <signin_link>connecter</signin_link> pour continuer.'
{{ 'auth_prompt'|trans_html(tags: {
    signin_link: { tag: 'a', attr: { href: path('login') } },
}, arguments: { name: user.name }) }}

Symfony ne rend que les balises déclarées et échappe tout le reste : le texte du message, les paramètres (y compris les paramètres globaux, un oubli corrigé pendant la revue de la PR), les valeurs d’attributs et les balises non déclarées. La sortie est marquée comme sûre, donc plus de |raw. Les attributs ne viennent jamais du fichier de traduction, ce qui règle au passage un défaut que tout le monde a croisé : les URL écrites en dur dans les YAML de traduction, qui cassent au premier changement de route.

Trois comportements sont à connaître avant de migrer. Un message dont les pseudo-balises ne sont pas équilibrées est échappé en entier : la phrase s’affiche avec ses chevrons au lieu de casser la page. Les éléments dont le contenu n’est pas échappable, script, style, iframe et quelques autres, sont refusés. Enfin, le filtre accepte aussi les messages construits avec t(), ce qui couvre les traductions préparées dans un contrôleur ou un formulaire.

Le même besoin côté PHP : les emails

Le HTML traduit ne vit pas que dans Twig. Les emails transactionnels construits en PHP en contiennent souvent, et ils échappent rarement à la revue de sécurité parce qu’on les croit internes. La PR ajoute pour eux une classe HtmlTranslator, enregistrée comme service translator.html et injectable par autowiring. Elle décore le traducteur au lieu d’ajouter une méthode à TranslatorInterface, un choix fait en revue pour que le profiler continue de voir passer les messages.

public function render(User $user): string
{
    return $this->htmlTranslator->trans('auth_prompt', [
        'signin_link' => ['tag' => 'a', 'attr' => ['href' => $this->urlGenerator->generate('login')]],
    ], ['name' => $user->getName()]);
}

L’ordre des arguments est celui du billet officiel : l’identifiant du message, la table des balises, puis les paramètres. La table des balises prend donc la deuxième place, celle que trans() réserve d’habitude aux paramètres : en convertissant un appel existant, c’est l’argument à ne pas laisser où il était.

Ne pas attendre la 8.2 : neutraliser les appels existants

Symfony 8.2 demande PHP 8.4 et sortira en novembre ; une application en 6.4 ou en 7.4 n’aura pas trans_html avant sa prochaine montée de version, et c’est très bien ainsi. Le trou, lui, se bouche tout de suite. Ce genre de ligne passe facilement entre les mailles : elle ne casse aucun test et elle franchit la revue de code, parce que |raw derrière |trans a l’air d’une affaire de mise en forme.

Première étape, l’inventaire. Ces trois recherches couvrent les cas que je vois le plus souvent : le filtre suivi de |raw, éventuellement avec d’autres filtres entre les deux, la balise avec paramètres, et les fichiers de traduction qui contiennent du HTML.

grep -rnE "\|\s*trans\b.*\|\s*raw\b" templates/
grep -rnE "\{%-?\s*trans\b[^%]*\bwith\b" templates/
grep -rlE "<[a-z]+[ >]" translations/

Je les ai exécutées sur un jeu de templates de test : elles remontent bien |trans({...})|raw, |trans|raw sans paramètre et |trans(...)|striptags|raw, ainsi que la balise {% trans with %}. Ce sont des grep, pas un analyseur Twig : un appel réparti sur plusieurs lignes leur échappe, il faut donc relire les templates où un fichier de traduction contient du HTML.

Deuxième étape, le tri. Un |trans|raw sans paramètre, sur un message dont le texte est versionné dans le dépôt, n’expose que ce que le traducteur a écrit : c’est une dette, pas une faille. Tout appel qui reçoit un paramètre venu d’un utilisateur, d’une base ou d’une API est à corriger en priorité.

Troisième étape, la correction minimale. En attendant trans_html, il suffit d’échapper chaque paramètre avant la substitution :

{{ 'welcome'|trans({'%name%': app.user.username|e})|raw }}
{% trans with {'%name%': app.user.username|e} %}welcome{% endtrans %}

Relancé dans le même conteneur avec le nom piégé, le script rend Bonjour &lt;img src=x onerror=alert(1)&gt;, <a href="/profil">voir votre profil</a>. : le lien reste un lien, la balise injectée redevient du texte. C’est une correction d’une ligne par appel, sans changement de fichier de traduction, qu’on peut livrer dans la semaine et remplacer plus tard par trans_html.

Ce que trans_html ne règle pas

Le filtre ne fait rien pour le code existant : tant qu’un template appelle |trans|raw, la nouvelle API ne le protège pas, et aucune dépréciation ne vous le signalera. L’inventaire reste à faire, version 8.2 ou non.

Il impose aussi une discipline aux traducteurs. Les pseudo-balises doivent rester équilibrées et porter exactement le nom attendu par le template, sinon le message s’affiche en texte brut avec ses chevrons. Avec une plateforme de traduction externe, cela se traduit par une règle de validation à ajouter, et par des allers-retours le temps que l’équipe de traduction s’y fasse. Un relecteur de la PR a par ailleurs signalé que la pseudo-localisation, avec son réglage par défaut parse_html: false, abîme ces pseudo-balises ; la discussion ne dit pas si c’est corrigé.

Enfin, échapper les paramètres ne rend pas sûr le HTML du traducteur lui-même. Si vos traductions sont modifiables depuis une interface d’administration ou importées d’un outil tiers, le message entier est une entrée non fiable, et seul trans_html (ou une purification HTML en sortie) répond à ce cas. Cet article s’appuie sur le billet de Symfony et sur la PR pour tout ce qui concerne la 8.2 ; le comportement de trans_html n’a pas été exécuté ici, seuls l’injection et la correction par |e l’ont été, sur Symfony 7.4.

Lundi matin, dans cet ordre

  • Lancez les trois grep sur templates/ et translations/, et comptez les appels qui passent des paramètres : c’est votre périmètre réel.
  • Corrigez d’abord les appels dont un paramètre vient d’une saisie, avec |e sur chaque paramètre, et ajoutez un test fonctionnel qui injecte une balise dans ce champ.
  • Interdisez les nouveaux |trans|raw : une règle dans la revue de code ou un grep en CI qui échoue au-delà du nombre actuel d’occurrences suffit.
  • Si vos traductions viennent d’un outil externe, demandez qui peut y écrire du HTML aujourd’hui.
  • Notez trans_html dans la liste des gains de votre prochaine montée de version, avec la conversion des URL écrites en dur dans les fichiers de traduction.

Le jour où l’équipe passera en 8.2, le travail consistera à remplacer des lignes déjà repérées et déjà sûres, et non à découvrir la faille dans l’urgence.

Sources

Sur ce sujet, l’offre de l’atelier : Migration Symfony

À lire aussi

Un socle à migrer, une architecture à fiabiliser ?

Renfort senior en régie, ou forfait à périmètre et prix fixés : l’atelier intervient à distance sur PHP, Symfony, les systèmes distribués et l’adoption des agents de code, avec des tests de caractérisation avant chaque migration.