Protection contre les DOM XSS avec les Trusted Types

BenjaminLe 20 septembre 2021

Les failles XSS (Cross-Site Scripting) font partie des failles les plus fréquentes que nous rencontrons durant nos pentests d'applications web. Elles proviennent d'un défaut de filtrage et d'encodage des saisies utilisateur. Celles-ci sont insérées telles-quelles au sein des pages web, et si elles contiennent du code JavaScript, ce dernier est exécuté. L'impact pour l'application peut alors aller du défacement, c'est à dire de la modification du contenu de la page, jusqu'à la subtilisation de la session de la victime sur l'application, ou de ses identifiants.

On catégorise généralement les failles XSS en trois types :

  • les failles XSS réfléchies, où les saisies utilisateurs sont envoyées dans une requête au serveur, puis affichées directement dans la réponse ;
  • les failles XSS stockées, où les saisies utilisateur sont stockées, puis affichées chaque fois que les données sont récupérées depuis le serveur ;
  • les failles XSS sur le DOM, où les saisies utilisateur sont insérées dans le code HTML de la page au moyen de fonctions JavaScript spécifiques.

Côté code, la manière de s'en prémunir est simple. Il faut encoder les saisies utilisateur afin que les caractères spécifiques qui pourraient interférer avec le code HTML de la page ne soient pas interprétés par le navigateur. De manière complémentaire, les saisies utilisateur devraient être filtrées afin d'éliminer le code HTML ou JavaScript superflu ou malicieux. Ces concepts de protection sont simples à comprendre, mais difficiles à appliquer.

En effet, sur les applications web récentes qui ont pu être développées et sécurisées à l'aide de technologies modernes, ce type de failles est ponctuel. Il est généralement assez simple pour les équipes de développement d'identifier le morceau de code en cause, le "trou dans la raquette" qui peut être corrigé rapidement. En revanche, pour les applications qui n'ont pas bénéficié des mêmes pratiques de développement, et qui ne sont pas maintenues par une solide équipe de développeurs, il peut y avoir de nombreux endroits où les contrôles sont manquants. Les identifier et les corriger peut être une tâche longue et difficile.

Quelques mitigations ont vu le jour côté socle afin de tenter de protéger les applications sans nécessiter de plonger dans les profondeurs du code, telles que :

  • les WAF (Web Application Firewall) : ce genre de solution a généralement un coût important, à la fois en terme de temps nécessaire pour le mettre en place et le maintenir dans le temps, qu'en termes de ressources sur la machine sur laquelle il est installé ;
  • l'entête X-XSS-Protection : cet entête permet d'activer le mécanisme de filtrage des XSS réfléchies dans les navigateurs, mais il est aujourd'hui déprécié et de nombreux navigateurs l'ont désactivé (Chrome, Edge), voire ne l'ont jamais implémenté (Firefox) ;
  • l'entête Content-Security-Policy : ce mécanisme permet de verrouiller les sources autorisées à charger du contenu JavaScript au sein d'une page, mais est rarement implémenté dans sa version complète (c'est à dire sans autoriser les scripts inline) ce qui réduit grandement son efficacité.

Nous vous présentons ici le mécanisme des Trusted Types, apparu il y a quelques années et développé à l'initiative de Google. Il permet de se protéger contre les failles XSS sur le DOM à moindre effort. Il pourrait constituer une solution de mitigation intéressante permettant de répondre à certains contextes.

Qu'est-ce que le DOM ?

Le DOM (Document Object Model) est une représentation hiérarchique des éléments HTML d'une page. Elle permet à une page HTML d'être manipulée en JavaScript facilement. Le DOM expose des API qui permettent de sélectionner des éléments (document.getElementById(), document.getElementsByTagName()...), de créer et ajouter des éléments (document.createElement(), node.appendChild()...), ou encore de manipuler des attributs (element.getAttribute(), element.setAttribute()...). Tous les développeurs web utilisant le JavaScript ont pu un jour ou un autre manipuler le DOM. Pour plus d'informations, vous pouvez consulter la documentation de Mozilla sur le sujet.

D'où viennent les failles XSS sur le DOM ?

Certaines fonctions permettent de manipuler directement le code HTML sans manipuler proprement le DOM : elles sont appelées sinks dans la littérature anglophone. Voici quelques unes de ces fonctions : eval(), setTimeout() , innerHTML(), appendChild(), ou encore append(). Un exemple fréquent est d'insérer du contenu dans une page. Souvent, cette opération est réalisée comme suit :

document.getElementById('ma-zone').innerHTML = '<p>Coucou, je suis du contenu HTML.</p>'

Alors que la manière propre de le faire s'écrit comme suit :

let monTexte = 'Coucou, je suis du contenu HTML.';
let monParagraphe = document.createElement('p');
monParagraphe.append(monTexte);
document.getElementById('ma-zone').appendChild(monParagraphe);

Vous l'aurez sans doute compris : si les données à insérer proviennent d'une source que l'utilisateur peut contrôler et qu'elles ne sont pas filtrées, qu'il s'agisse d'un champ dans la page, d'un morceau d'URL, ou même de données récupérées du serveur en Ajax, cela donnera lieu à une DOM XSS.

Une liste plus exhaustive est indiquée ici, à la rubrique "Sinks".

Comment fonctionnent les Trusted Types ?

Les Trusted Types limitent l'utilisation de ces API dangereuses, ou sinks. Lorsque ce mécanisme est activé, le navigateur refusera d'envoyer des chaînes de caractères à ces fonctions dangereuses, car il ne les considèrera pas comme de confiance. Le navigateur autorisera uniquement des Trusted Types en entrée de ces fonctions.

Pour autant, cela ne veut pas dire qu'il faut repasser sur tous les endroits du code qui font usage de ces fonctions ! Il est possible de définir un comportement par défaut qui fait automatiquement passer les données envoyées en entrée de ces fonctions dangereuses à travers un filtre, le plus basique s'écrivant en trois lignes de code.

Au final, la mise en place la plus basique des Trusted Types sur une application ne nécessite que quelques lignes de code et l'activation d'un entête de sécurité !

Comment implémenter les Trusted Types ?

Première étape : coder la fonction à laquelle seront passées automatiquement toutes les chaînes de caractères qui seront envoyées aux fonctions dangereuses. La fonction suivante encode simplement les chevrons (< et >) dans leur représentation HTML, qui seront insérés dans la page sans être interprétés par le navigateur.

const defaultPolicy = trustedTypes.createPolicy('default', {
    createHTML(dirtyString) { return dirtyString.replace(/\</g, '&lt;').replace(/\>/g, '&gt;') }
});

Deuxième étape : activer les Trusted Types au sein de votre Content-Security-Policy. Vous n'en avez pas ? Aucun problème, il est possible de déclarer une CSP juste pour activer les Trusted Types, sans avoir à configurer tout le reste. Bien évidemment, nous vous conseillons de lire notre article sur l'implémentation d'une Content-Security-Policy afin de faire les choses proprement, mais la CSP suivante suffit pour notre besoin présent :

Content-Security-Policy: require-trusted-types-for 'script';

Troisième étape : intégrer le polyfill des Trusted Types. En effet, ce mécanisme n’étant pas encore intégré nativement par tous les navigateurs (Firefox et Safari sont à la traîne, à l'heure où l'on rédige ces lignes), il est nécessaire d'inclure une librairie qui permettra de gérer ce mécanisme même dans les navigateurs non supportés : c'est ce qu'on appelle un polyfill. Lorsque tous les navigateurs auront implémenté ce mécanisme, le polyfill ne sera plus nécessaire. Celui-ci a été développé par un ingénieur de Google et a été validé par le W3C : webappsec-trusted-types (fichier dist/es6/trustedtypes.build.js).

<script src="trustedtypes.build.js"></script>

Le Codepen suivant vous permettra de tester le mécanisme en direct.

Comme vous le verrez en cliquant sur le bouton "Afficher les données", toutes les données malicieuses de la zone de saisie seront insérées proprement dans la page, et ne déclencheront pas l'exécution des potentiels code JavaScript qui pourraient s'y trouver. Notre politique de Trusted Types est automatiquement intervenue lorsque la fonction JavaScript dangereuse .innerHTML() a été appelée pour insérer des données dans la page, sans avoir besoin de rajouter du code.

Maintenant, sur la page Codepen, cliquez sur le bouton "Settings" dans la barre tout en haut, et dans la rubrique HTML > Stuff for <head>, supprimez la balise meta de la CSP, puis cliquez sur "Close". Supprimez également les 3 premières lignes du cadre JS qui définissent la Default Policy. Attendez que le Codepen se recharge, puis cliquez sur le bouton "Afficher les données" : vous devriez voir apparaître la petite popup traditionnelle de preuve de concept. N'hésitez pas à tester d'autres payloads !

Les politiques de Trusted Types

Nous avons vu jusqu'à présent l'implémentation simple des Trusted Types : une seule politique, nommée default, a été définie, et intervient automatiquement à chaque appel d'une fonction JavaScript dangereuse. Il peut cependant y avoir des cas où vous ne souhaitez pas que la politique par défaut soit utilisée : vous aurez alors la nécessité de développer des politiques spécifiques, c'est ce que nous allons voir à présent.

Considérons deux besoins :

  • le premier est de pouvoir interpréter tout type de code HTML, hormis du code dangereux ;
  • le second est de pouvoir interpréter seulement quelques balises de code HTML

Nous utiliserons pour ces besoins la librairie DOMPurify de cure53. Le but n'est pas de faire un guide d'utilisation de cette librairie, nous ne nous étendrons donc pas sur son utilisation. N'hésitez pas à consulter la documentation de leur dépôt Github pour davantage d'informations sur son usage.

On charge la librairie :

<script src="purify.min.js"></script>

On ajoute deux nouvelles politiques de Trusted Types correspondant aux deux besoins cités ci-dessus :

const simpleSanitizer = trustedTypes.createPolicy('simple-dompurify-sanitize', {
    createHTML(dirty) { return DOMPurify.sanitize(dirty, {RETURN_TRUSTED_TYPE: true}) }
});

const advancedSanitizer = trustedTypes.createPolicy('advanced-dompurify-sanitize', {
    createHTML(dirty) { return DOMPurify.sanitize(dirty, {RETURN_TRUSTED_TYPE: true, ALLOWED_TAGS: ['strong','b','em','p']}) }
});

À la différence du premier exemple, il va cette fois-ci falloir modifier le code JavaScript afin de faire appel à l'une ou l'autre de ces politiques :

document.getElementById("divRender2").innerHTML = simpleSanitizer.createHTML(data);
document.getElementById("divRender3").innerHTML = advancedSanitizer.createHTML(data);

La politique default demeure afin de protéger les appels de fonctions dangereuses qui n'auront pas été explicitement protégés par l'une de ces deux nouvelles politiques.

Vous pouvez consulter le Codepen suivant afin de constater le fonctionnement de ces deux nouvelles politiques, en complément de la politique par défaut. Là encore, vous pouvez vous rendre dans les Settings pour retirer la CSP, et supprimer les 11 premières lignes de JavaScript définissant les trois politiques de Trusted Types, afin de voir la différence avec une situation non sécurisée.

Dans l'absolu, nos deux nouvelles politiques de Trusted Types sont applicables sans avoir eu à modifier la Content-Security-Policy. Toutefois, il est préférable de lister les différentes politiques autorisées. On rajoute donc l'instruction trusted-types à notre CSP, suivie de la liste des politiques créées, y compris celle par défaut.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify simple-dompurify-sanitize advanced-dompurify-sanitize;

Conclusion

Nous avons donc pu étudier le mécanisme des Trusted Types qui permet de protéger facilement une application web contre l'un des trois types de failles XSS présents dans les applications web. Il est important de garder à l'esprit que ce mécanisme est encore en cours d'élaboration, et que tous les navigateurs ne l'ont pas encore implémenté, l'utilisation d'un polyfill restant nécessaire. Il ne s'agit donc aucunement d'une solution fiable et unique contre tous les types de XSS. Néanmoins, elle peut s'intégrer dans une stratégie de sécurité en couches, en complément d'une CSP solide et d'un filtrage et encodage systématique des saisies utilisateur.

Vous avez activé l'option "Do Not Track" dans votre navigateur, nous respectons ce choix et ne suivons pas votre visite.