Jouez en toute sécurité avec des iFrames en bac à sable

Pour créer une expérience riche sur le Web d'aujourd'hui, il est presque inévitable d'intégrer des composants et du contenu sur lesquels vous n'avez aucun contrôle réel. Les widgets tiers peuvent stimuler l'engagement et jouer un rôle essentiel dans l'expérience utilisateur globale. Parfois, le contenu généré par l'utilisateur est même plus important que le contenu natif d'un site. Vous ne pouvez pas vraiment vous en passer, mais les deux augmentent le risque qu'un problème survienne sur votre site. Chaque widget que vous intégrez (chaque annonce, chaque widget de réseau social) est un vecteur d'attaque potentiel pour les personnes malveillantes :

La Content Security Policy (CSP) peut atténuer les risques associés à ces deux types de contenu en vous permettant d'ajouter à la liste d'autorisation des sources de confiance spécifiques pour les scripts et d'autres contenus. Il s'agit d'un grand pas dans la bonne direction, mais il convient de noter que la protection offerte par la plupart des directives CSP est binaire : la ressource est autorisée ou non. Il peut arriver que vous vous disiez : "Je ne suis pas sûr de faire confiance à cette source de contenu, mais elle est tellement jolie ! Intègre-le s'il te plaît, navigateur, mais ne le laisse pas casser mon site."

Moindre privilège

En substance, nous recherchons un mécanisme qui nous permettra d'accorder au contenu que nous intégrons uniquement le niveau minimal de fonctionnalités nécessaire pour effectuer son travail. Si un widget n'a pas besoin d'ouvrir une nouvelle fenêtre, supprimer l'accès à window.open ne peut pas faire de mal. Si ce n'est pas le cas, la désactivation de la compatibilité avec les plug-ins ne devrait pas poser de problème. Nous sommes aussi sécurisés que possible si nous suivons le principe du moindre privilège et bloquons toutes les fonctionnalités qui ne sont pas directement liées à celles que nous souhaitons utiliser. Par conséquent, nous n'avons plus à faire aveuglément confiance à un contenu intégré pour qu'il ne profite pas de privilèges qu'il ne devrait pas utiliser. Il n'aura tout simplement pas accès à la fonctionnalité.

Les éléments iframe constituent la première étape vers un bon framework pour une telle solution. Le chargement d'un composant non fiable dans un iframe permet de séparer votre application du contenu que vous souhaitez charger. Le contenu encadré n'aura pas accès au DOM de votre page ni aux données que vous avez stockées localement, et il ne pourra pas dessiner à des positions arbitraires sur la page. Il est limité au contour du cadre. Toutefois, la séparation n'est pas vraiment robuste. La page contenue dispose toujours d'un certain nombre d'options pour un comportement gênant ou malveillant : la lecture automatique de vidéos, les plug-ins et les pop-ups ne sont que la partie émergée de l'iceberg.

L'attribut sandbox de l'élément iframe nous donne exactement ce dont nous avons besoin pour renforcer les restrictions sur le contenu encadré. Nous pouvons demander au navigateur de charger le contenu d'un frame spécifique dans un environnement à faibles privilèges, en n'autorisant que le sous-ensemble de fonctionnalités nécessaires pour effectuer le travail requis.

La confiance n'exclut pas le contrôle

Le bouton "Tweet" de Twitter est un bon exemple de fonctionnalité qui peut être intégrée de manière plus sécurisée sur votre site via un bac à sable. Twitter vous permet d'intégrer le bouton via un iframe avec le code suivant :

<iframe src="https://platform.twitter.com/widgets/tweet_button.html"
        style="border: 0; width:130px; height:20px;"></iframe>

Pour déterminer ce que nous pouvons verrouiller, examinons attentivement les fonctionnalités requises par le bouton. Le code HTML chargé dans le frame exécute un peu de code JavaScript provenant des serveurs de Twitter et génère un pop-up contenant une interface de tweet lorsque l'utilisateur clique dessus. Cette interface doit accéder aux cookies de Twitter pour associer le tweet au bon compte et doit pouvoir envoyer le formulaire de tweet. C'est à peu près tout. Le frame n'a pas besoin de charger de plug-ins, de naviguer dans la fenêtre de niveau supérieur ni d'utiliser d'autres fonctionnalités. Comme il n'en a pas besoin, supprimons-les en plaçant le contenu du frame dans un bac à sable.

L'exécution en bac à sable fonctionne sur la base d'une liste d'autorisation. Nous commençons par supprimer toutes les autorisations possibles, puis nous réactivons les fonctionnalités individuelles en ajoutant des options spécifiques à la configuration du bac à sable. Pour le widget Twitter, nous avons décidé d'activer JavaScript, les pop-ups, l'envoi de formulaires et les cookies de twitter.com. Pour ce faire, ajoutez un attribut sandbox à iframe avec la valeur suivante :

<iframe sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
    src="https://platform.twitter.com/widgets/tweet_button.html"
    style="border: 0; width:130px; height:20px;"></iframe>

Et voilà ! Nous avons accordé au frame toutes les fonctionnalités dont il a besoin, et le navigateur lui refusera l'accès à tous les droits que nous ne lui avons pas explicitement accordés via la valeur de l'attribut sandbox.

Contrôle précis des fonctionnalités

Nous avons vu quelques-uns des indicateurs de bac à sable possibles dans l'exemple ci-dessus. Examinons maintenant plus en détail le fonctionnement interne de l'attribut.

Si un iFrame possède un attribut de bac à sable vide, le document encadré sera entièrement placé dans un bac à sable, ce qui le soumettra aux restrictions suivantes :

  • Le code JavaScript ne s'exécutera pas dans le document encadré. Cela inclut non seulement le code JavaScript explicitement chargé via des balises de script, mais aussi les gestionnaires d'événements intégrés et les URL javascript:. Cela signifie également que le contenu des balises noscript sera affiché, exactement comme si l'utilisateur avait désactivé le script lui-même.
  • Le document encadré est chargé dans une origine unique, ce qui signifie que toutes les vérifications de même origine échoueront. Les origines uniques ne correspondent jamais à aucune autre origine, pas même à elles-mêmes. Entre autres conséquences, cela signifie que le document n'a pas accès aux données stockées dans les cookies d'une origine ou dans tout autre mécanisme de stockage (stockage DOM, Indexed DB, etc.).
  • Le document encadré ne peut pas créer de fenêtres ni de boîtes de dialogue (via window.open ou target="_blank", par exemple).
  • Impossible d'envoyer le formulaire.
  • Les plug-ins ne se chargeront pas.
  • Le document encadré ne peut naviguer que lui-même, et non son parent de niveau supérieur. La définition de window.top.location génère une exception, et cliquer sur un lien avec target="_top" n'a aucun effet.
  • Les fonctionnalités qui se déclenchent automatiquement (éléments de formulaire avec mise au point automatique, vidéos en lecture automatique, etc.) sont bloquées.
  • Impossible d'obtenir le verrouillage du pointeur.
  • L'attribut seamless est ignoré sur iframes que contient le document encadré.

Cette mesure est draconienne, et un document chargé dans un iframe entièrement isolé présente très peu de risques. Bien sûr, il ne peut pas non plus faire grand-chose d'utile : vous pouvez peut-être vous en sortir avec un bac à sable complet pour certains contenus statiques, mais la plupart du temps, vous voudrez assouplir un peu les choses.

À l'exception des plug-ins, chacune de ces restrictions peut être levée en ajoutant un flag à la valeur de l'attribut sandbox. Les documents en bac à sable ne peuvent jamais exécuter de plug-ins, car les plug-ins sont du code natif non placé en bac à sable, mais tout le reste est autorisé :

  • allow-forms permet l'envoi de formulaires.
  • allow-popups autorise les pop-ups (incroyable !).
  • allow-pointer-lock permet (surprise !) de verrouiller le pointeur.
  • allow-same-origin permet au document de conserver son origine. Les pages chargées à partir de https://example.com/ conservent l'accès aux données de cette origine.
  • allow-scripts autorise l'exécution de JavaScript et permet également aux fonctionnalités de se déclencher automatiquement (car elles seraient faciles à implémenter via JavaScript).
  • allow-top-navigation permet au document de sortir du cadre en accédant à la fenêtre de premier niveau.

En gardant cela à l'esprit, nous pouvons évaluer exactement pourquoi nous avons obtenu l'ensemble spécifique d'options de bac à sable dans l'exemple Twitter ci-dessus :

  • allow-scripts est requis, car la page chargée dans le frame exécute du code JavaScript pour gérer l'interaction de l'utilisateur.
  • allow-popups est requis, car le bouton affiche un formulaire de tweet dans une nouvelle fenêtre.
  • allow-forms est obligatoire, car le formulaire de tweet doit pouvoir être envoyé.
  • allow-same-origin est nécessaire, car sinon les cookies de twitter.com seraient inaccessibles et l'utilisateur ne pourrait pas se connecter pour publier le formulaire.

Il est important de noter que les indicateurs de bac à sable appliqués à un frame s'appliquent également à toutes les fenêtres ou frames créés dans le bac à sable. Cela signifie que nous devons ajouter allow-forms au bac à sable du frame, même si le formulaire n'existe que dans la fenêtre qui s'affiche.

Une fois l'attribut sandbox en place, le widget n'obtient que les autorisations dont il a besoin, et les fonctionnalités telles que les plug-ins, la navigation supérieure et le verrouillage du pointeur restent bloquées. Nous avons réduit le risque d'intégrer le widget, sans aucun effet négatif. Tout le monde y gagne.

Séparation des privilèges

Le sandboxing de contenus tiers afin d'exécuter leur code non fiable dans un environnement à faibles privilèges est un avantage évident. Mais qu'en est-il de votre propre code ? Vous vous faites confiance, n'est-ce pas ? Alors pourquoi s'inquiéter du bac à sable ?

Je retournerais la question : si votre code n'a pas besoin de plug-ins, pourquoi lui donner accès à des plug-ins ? Au mieux, il s'agit d'un privilège que vous n'utilisez jamais. Au pire, il s'agit d'un vecteur potentiel permettant aux pirates d'accéder à votre système. Le code de chacun comporte des bugs, et pratiquement toutes les applications sont vulnérables à l'exploitation d'une manière ou d'une autre. Le fait de placer votre propre code dans un bac à sable signifie que même si un pirate informatique parvient à détourner votre application, il n'aura pas un accès complet à l'origine de l'application. Il ne pourra faire que ce que l'application peut faire. C'est toujours une mauvaise chose, mais pas autant que cela pourrait l'être.

Vous pouvez réduire encore davantage le risque en divisant votre application en parties logiques et en plaçant chacune d'elles dans un bac à sable avec le moins de privilèges possible. Cette technique est très courante dans le code natif. Par exemple, Chrome se divise en un processus de navigateur à privilèges élevés qui a accès au disque dur local et peut établir des connexions réseau, et en de nombreux processus de moteur de rendu à privilèges faibles qui effectuent le gros du travail d'analyse du contenu non fiable. Les moteurs de rendu n'ont pas besoin de toucher au disque, le navigateur se charge de leur fournir toutes les informations dont ils ont besoin pour afficher une page. Même si un pirate informatique astucieux trouve un moyen de corrompre un moteur de rendu, il n'ira pas très loin, car le moteur de rendu ne peut pas faire grand-chose d'intéressant par lui-même : tous les accès à privilèges élevés doivent être acheminés par le processus du navigateur. Les pirates informatiques devront trouver plusieurs failles dans différentes parties du système pour causer des dommages, ce qui réduit considérablement le risque de piratage réussi.

Exécution sécurisée de eval() dans un bac à sable

Avec le sandboxing et l'API postMessage, le succès de ce modèle est assez simple à appliquer au Web. Certaines parties de votre application peuvent se trouver dans des iframe en bac à sable, et le document parent peut servir d'intermédiaire entre elles en publiant des messages et en écoutant les réponses. Ce type de structure garantit que les failles d'une partie de l'application causent le moins de dommages possible. Elle présente également l'avantage de vous obliger à créer des points d'intégration clairs, de sorte que vous sachiez exactement où vous devez faire attention à la validation des entrées et des sorties. Prenons un exemple simple pour voir comment cela fonctionne.

Evalbox est une application intéressante qui prend une chaîne et l'évalue en tant que JavaScript. Impressionnant, n'est-ce pas ? C'est exactement ce que vous attendiez depuis toutes ces années. Il s'agit bien sûr d'une application assez dangereuse, car l'autorisation d'exécuter du code JavaScript arbitraire signifie que toutes les données qu'une origine peut offrir sont accessibles. Nous allons limiter le risque que des problèmes surviennent en nous assurant que le code est exécuté dans un bac à sable, ce qui le rend beaucoup plus sûr. Nous allons parcourir le code de l'intérieur vers l'extérieur, en commençant par le contenu du cadre :

<!-- frame.html -->
<!DOCTYPE html>
<html>
    <head>
    <title>Evalbox's Frame</title>
    <script>
        window.addEventListener('message', function (e) {
        var mainWindow = e.source;
        var result = '';
        try {
            result = eval(e.data);
        } catch (e) {
            result = 'eval() threw an exception.';
        }
        mainWindow.postMessage(result, event.origin);
        });
    </script>
    </head>
</html>

Dans le frame, nous avons un document minimal qui écoute simplement les messages de son parent en se connectant à l'événement message de l'objet window. Chaque fois que le parent exécute postMessage sur le contenu de l'iframe, cet événement se déclenche, ce qui nous donne accès à la chaîne que notre parent souhaite que nous exécutions.

Dans le gestionnaire, nous récupérons l'attribut source de l'événement, qui est la fenêtre parente. Nous l'utiliserons pour renvoyer le résultat de notre travail une fois que nous aurons terminé. Nous allons ensuite effectuer le gros du travail en transmettant les données que nous avons reçues à eval(). Cet appel a été encapsulé dans un bloc try, car les opérations interdites dans un iframe en bac à sable génèrent fréquemment des exceptions DOM. Nous les intercepterons et afficherons un message d'erreur convivial à la place. Enfin, nous renvoyons le résultat à la fenêtre parente. C'est assez simple.

Le parent est tout aussi simple. Nous allons créer une petite interface utilisateur avec un textarea pour le code et un button pour l'exécution, et nous allons extraire frame.html via un iframe en bac à sable, n'autorisant que l'exécution du script :

<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
<iframe sandbox='allow-scripts'
        id='sandboxed'
        src='frame.html'></iframe>

Nous allons maintenant configurer l'exécution. Tout d'abord, nous allons écouter les réponses de iframe et les alert() à nos utilisateurs. On peut supposer qu'une application réelle ferait quelque chose de moins ennuyeux :

window.addEventListener('message',
    function (e) {
        // Sandboxed iframes which lack the 'allow-same-origin'
        // header have "null" rather than a valid origin. This means you still
        // have to be careful about accepting data via the messaging API you
        // create. Check that source, and validate those inputs!
        var frame = document.getElementById('sandboxed');
        if (e.origin === "null" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

Ensuite, nous allons associer un gestionnaire d'événements aux clics sur le button. Lorsque l'utilisateur clique, nous récupérons le contenu actuel de textarea et le transmettons au frame pour exécution :

function evaluate() {
    var frame = document.getElementById('sandboxed');
    var code = document.getElementById('code').value;
    // Note that we're sending the message to "*", rather than some specific
    // origin. Sandboxed iframes which lack the 'allow-same-origin' header
    // don't have an origin which you can target: you'll have to send to any
    // origin, which might alow some esoteric attacks. Validate your output!
    frame.contentWindow.postMessage(code, '*');
}

document.getElementById('safe').addEventListener('click', evaluate);

Facile, n'est-ce pas ? Nous avons créé une API d'évaluation très simple, et nous pouvons être sûrs que le code évalué n'a pas accès aux informations sensibles telles que les cookies ou le stockage DOM. De même, le code évalué ne peut pas charger de plug-ins, ouvrir de nouvelles fenêtres ni effectuer d'autres activités gênantes ou malveillantes.

Vous pouvez faire de même pour votre propre code en divisant les applications monolithiques en composants à usage unique. Chacun peut être encapsulé dans une API de messagerie simple, comme celle que nous avons écrite ci-dessus. La fenêtre parente à privilèges élevés peut agir en tant que contrôleur et répartiteur, en envoyant des messages à des modules spécifiques qui disposent chacun du moins de privilèges possible pour effectuer leurs tâches, en écoutant les résultats et en s'assurant que chaque module ne reçoit que les informations dont il a besoin.

Notez toutefois que vous devez être très prudent lorsque vous traitez du contenu encadré provenant de la même origine que le parent. Si une page sur https://example.com/ encadre une autre page de la même origine avec un bac à sable qui inclut à la fois les indicateurs allow-same-origin et allow-scripts, la page encadrée peut atteindre le parent et supprimer complètement l'attribut de bac à sable.

Jouer dans votre bac à sable

Le sandboxing est disponible dès maintenant dans différents navigateurs : Firefox 17 et versions ultérieures, IE10 et versions ultérieures, et Chrome au moment de la rédaction de cet article (caniuse, bien sûr, dispose d'un tableau de compatibilité à jour). L'application de l'attribut sandbox à l'élément iframes que vous incluez vous permet d'accorder certains droits au contenu qu'il affiche, uniquement ceux qui sont nécessaires au bon fonctionnement du contenu. Cela vous permet de réduire le risque associé à l'inclusion de contenus tiers, au-delà de ce qui est déjà possible avec la Content Security Policy.

De plus, le bac à sable est une technique puissante pour réduire le risque qu'un pirate informatique astucieux puisse exploiter les failles de votre propre code. En séparant une application monolithique en un ensemble de services en bac à sable, chacun responsable d'une petite partie de fonctionnalité autonome, les pirates informatiques seront contraints de compromettre non seulement le contenu de frames spécifiques, mais également leur contrôleur. C'est une tâche beaucoup plus difficile, d'autant plus que le champ d'application du contrôleur peut être considérablement réduit. Vous pouvez consacrer vos efforts de sécurité à l'audit de ce code si vous demandez de l'aide au navigateur pour le reste.

Cela ne signifie pas que le bac à sable est une solution complète au problème de sécurité sur Internet. Il offre une défense en profondeur, et à moins que vous ne contrôliez les clients de vos utilisateurs, vous ne pouvez pas encore compter sur la compatibilité des navigateurs pour tous vos utilisateurs (si vous contrôlez les clients de vos utilisateurs, par exemple dans un environnement d'entreprise, tant mieux !). Un jour, peut-être… Mais pour l'instant, l'exécution en bac à sable est une couche de protection supplémentaire pour renforcer vos défenses, et non une défense complète sur laquelle vous pouvez vous reposer entièrement. Toutefois, les couches sont excellentes. Je vous suggère d'utiliser celui-ci.

Documentation complémentaire

  • "Privilege Separation in HTML5 Applications" est un article intéressant qui traite de la conception d'un petit framework et de son application à trois applications HTML5 existantes.

  • Le sandboxing peut être encore plus flexible lorsqu'il est combiné à deux autres nouveaux attributs d'iframe : srcdoc et seamless. Le premier vous permet de remplir un frame avec du contenu sans la surcharge d'une requête HTTP, et le second permet au style de s'intégrer au contenu du frame. Pour le moment, la compatibilité avec les navigateurs est assez limitée (Chrome et WebKit Nightly), mais cette combinaison sera intéressante à l'avenir. Par exemple, vous pouvez placer les commentaires d'un article dans un bac à sable à l'aide du code suivant :

        <iframe sandbox seamless
                srcdoc="<p>This is a user's comment!
                           It can't execute script!
                           Hooray for safety!</p>"></iframe>