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

Pour créer une expérience enrichie sur le Web d'aujourd'hui, vous devez inévitablement intégrer des composants et du contenu sur lesquels vous n'avez aucun contrôle réel. Les widgets tiers peuvent susciter l'engagement et jouer un rôle essentiel dans l'expérience utilisateur globale. Le contenu généré par l'utilisateur est parfois encore plus important que le contenu natif d'un site. S'abstenir de l'un ou de l'autre n'est pas vraiment une option, mais les deux augmentent le risque que quelque chose de mauvais™ se produise 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 stratégie de sécurité du contenu (CSP) peut atténuer les risques associés à ces deux types de contenus en vous permettant d'ajouter à la liste blanche des sources de script et d'autres contenus particulièrement fiables. Il s'agit d'une avancée majeure 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. Parfois, il peut être utile de dire "Je ne suis pas sûr de faire confiance à cette source de contenu, mais elle est tellement jolie ! Intégrez-le, s'il vous plaît, navigateur, mais ne laissez pas cela endommager mon site."

Principe du 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écessaires à son fonctionnement. Si un widget n'a pas besoin d'afficher une nouvelle fenêtre, il est préférable de supprimer l'accès à window.open. Si l'application n'a pas besoin de Flash, la désactivation de la compatibilité avec le plug-in ne devrait pas poser de problème. Nous assurons une sécurité optimale 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 aimerions utiliser. Par conséquent, nous n'avons plus à faire confiance aveuglément à un élément de contenu intégré pour qu'il n'abuse pas des 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 approuvé dans une iframe permet de séparer votre application du contenu que vous souhaitez charger. Le contenu encadré n'a pas accès au DOM de votre page ni aux données que vous avez stockées localement. Il ne peut pas non plus dessiner à des positions arbitraires sur la page. Son champ d'application est limité au contour du frame. Cependant, la séparation n'est pas vraiment solide. La page contenue présente toujours un certain nombre d'options en cas de comportement agaçant ou malveillant: la lecture automatique des vidéos, les plug-ins et les pop-ups sont la partie visible de l'iceberg.

L'attribut sandbox de l'élément iframe nous fournit tout 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 à faible privilège, en n'autorisant que le sous-ensemble de fonctionnalités nécessaires pour effectuer le travail requis.

Faire un petit test, mais vérifier

Le bouton "Tweet" de Twitter est un excellent exemple de fonctionnalité pouvant être intégrée plus en toute sécurité à 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 à partir des serveurs de Twitter et génère une fenêtre pop-up remplie d'une interface de tweet lorsque l'utilisateur clique dessus. Cette interface doit avoir accès aux cookies de Twitter pour associer le tweet au bon compte et pouvoir envoyer le formulaire de tweet. C'est à peu près tout. Le frame n'a pas besoin de charger de plug-ins, ni de naviguer dans la fenêtre de niveau supérieur, ni de nombreuses autres fonctionnalités. Étant donné qu'il n'a pas besoin de ces droits, supprimons-les en bac à sable au contenu du frame.

Le bac à sable fonctionne sur la base d'une liste blanche. Nous commençons par supprimer toutes les autorisations possibles, puis nous réactivons des fonctionnalités individuelles en ajoutant des indicateurs 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 au 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 attribué au frame toutes les fonctionnalités dont il a besoin, et le navigateur lui refusera l'accès à tous les privilèges 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.

Avec un iFrame avec un attribut de bac à sable vide, le document encadré sera entièrement en bac à sable et soumis aux restrictions suivantes:

  • JavaScript ne s'exécute pas dans le document encadré. Cela inclut non seulement le code JavaScript chargé explicitement via des tags de script, mais aussi les gestionnaires d'événements intégrés et les URL JavaScript. Cela signifie également que le contenu contenu dans les 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 à d'autres origines, même à elles-mêmes. Parmi les autres conséquences, cela signifie que le document n'a pas accès aux données stockées dans les cookies d'une origine ni à tout autre mécanisme de stockage (stockage DOM, base de données indexée, 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 des formulaires.
  • Les plug-ins ne se chargent pas.
  • Le document encadré ne peut naviguer que vers lui-même, et non vers son parent de niveau supérieur. Définir window.top.location génère une exception, et cliquer sur le 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 le document encadré contient.

C'est très draconien, et un document chargé dans un iframe entièrement en bac à sable ne présente que très peu de risques. Bien entendu, il ne peut pas non plus être très utile : vous pouvez vous contenter d'un bac à sable complet pour certains contenus statiques, mais la plupart du temps, vous devrez assouplir un peu les choses.

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

  • allow-forms permet d'envoyer le formulaire.
  • allow-popups autorise (surprise !) les pop-up.
  • 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 permet l'exécution JavaScript et permet également de déclencher automatiquement des fonctionnalités (car elles seraient faciles à implémenter via JavaScript).
  • allow-top-navigation permet au document de sortir du cadre en parcourant la fenêtre de premier niveau.

En tenant compte de ces éléments, nous pouvons évaluer exactement pourquoi nous avons obtenu cet ensemble spécifique d'indicateurs de bac à sable dans l'exemple Twitter ci-dessus:

  • allow-scripts est obligatoire, car la page chargée dans le frame exécute du code JavaScript pour gérer l'interaction utilisateur.
  • allow-popups est obligatoire, 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 les cookies de twitter.com seraient autrement inaccessibles et l'utilisateur ne pourrait pas se connecter pour publier le formulaire.

Notez que les indicateurs de bac à sable appliqués à un frame s'appliquent également à toutes les fenêtres ou frames créées 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 dans laquelle le frame s'affiche.

Avec 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 en haut de l'écran et le verrouillage du pointeur restent bloquées. Nous avons réduit le risque d'intégration du widget, sans effet négatif. Tout le monde y gagne.

Séparation des privilèges

Il est assez évident que le bac à sable de contenu tiers pour exécuter leur code non approuvé dans un environnement à privilèges limités est bénéfique. Mais qu'en est-il de votre propre code ? Tu as confiance en soi, n'est-ce pas ? Pourquoi se soucier du bac à sable ?

Je voudrais inverser 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 pour les pirates informatiques pour pénétrer dans votre système. Le code de tout le monde contient des bugs, et pratiquement toutes les applications sont vulnérables à l'exploitation d'une manière ou d'une autre. Mettre en bac à sable votre propre code signifie que même si un pirate informatique détourne votre application avec succès, il ne disposera pas d'un accès complet à l'origine de l'application. Il ne pourra que faire ce que l'application pourrait faire. Même si cela est mauvais, mais pas aussi mauvais qu'il pourrait l'être.

Vous pouvez réduire encore plus le risque en divisant votre application en éléments logiques et en mettant chaque élément en bac à sable avec les droits d'accès les plus limités possible. Cette technique est très courante dans le code natif: par exemple, Chrome se prive d'un processus de navigateur à privilèges élevés qui a accès au disque dur local et peut établir des connexions réseau, ainsi que de nombreux processus de moteur de rendu à faible privilège qui se chargent d'analyser du contenu non approuvé. Les moteurs de rendu n'ont pas besoin de toucher le disque : le navigateur leur fournit toutes les informations nécessaires au rendu d'une page. Même si un pirate informatique astucieux trouve le 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 par lui-même : tous les accès à privilège élevé doivent être acheminés via le processus du navigateur. Les pirates informatiques doivent trouver plusieurs failles dans différentes parties du système pour causer des dommages, ce qui réduit considérablement le risque de piratage.

Mise en bac à sable sécurisée de eval()

Avec le bac à sable et l'API postMessage, le succès de ce modèle est assez simple à appliquer au Web. Des éléments de votre application peuvent résider dans des iframes en bac à sable, et le document parent peut assurer la médiation de la communication entre eux en publiant des messages et en écoutant les réponses. Ce type de structure garantit que les exploits dans une partie de l'application causent le moins de dégâts possible. Elle présente également l'avantage de vous obliger à créer des points d'intégration clairs, ce qui vous permet de savoir exactement où vous devez faire attention lors de la validation des entrées et des sorties. Prenons un exemple fictif pour voir comment cela peut fonctionner.

Evalbox est une application intéressante qui prend une chaîne et l'évalue en tant que code JavaScript. C'est incroyable, non ? C'est exactement ce que vous attendiez depuis toutes ces longues années. Il s'agit bien sûr d'une application assez dangereuse, car en autorisant l'exécution de JavaScript arbitraire, toutes les données qu'une origine a à offrir sont prêtes à être récupérées. Nous allons atténuer le risque de "mauvaises choses" en nous assurant que le code est exécuté dans un bac à sable, ce qui le rend beaucoup plus sûr. Nous allons examiner le code de l'intérieur vers l'extérieur, en commençant par le contenu du frame:

<!-- 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 s'accrochant à 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 le parent souhaite que nous exécutions.

Dans le gestionnaire, nous récupérons l'attribut source de l'événement, qui correspond à la fenêtre parente. Nous l'utiliserons pour renvoyer le résultat de notre travail en amont une fois que nous aurons terminé. Ensuite, nous effectuerons le plus gros du travail en transmettant les données qui nous ont été fournies dans 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 signalerons plutôt un message d'erreur convivial. Enfin, nous publions le résultat dans la fenêtre parent. Il n'y a rien de très compliqué.

Le parent est tout aussi simple. Nous allons créer une petite UI 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 de 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 connecter les éléments pour l'exécution. Tout d'abord, nous écoutons les réponses de l'iframe et les alert() à nos utilisateurs. Une application réelle ferait probablement 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 connecter un gestionnaire d'événements aux clics sur button. Lorsque l'utilisateur clique, nous récupérons le contenu actuel de textarea et le transmettons au frame pour l'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 à des informations sensibles telles que les cookies ou le stockage DOM. De même, le code évalué ne peut pas charger de plug-ins, afficher de nouvelles fenêtres ni effectuer un certain nombre 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. Chacune peut être encapsulée dans une API de messagerie simple, comme nous l'avons indiqué ci-dessus. La fenêtre parente à privilèges élevés peut agir en tant que contrôleur et répartiteur, en envoyant des messages dans des modules spécifiques qui ont chacun le moins de privilèges possible pour effectuer leur travail, en écoutant les résultats et en veillant à ce que chaque module ne reçoive que les informations dont il a besoin.

Notez toutefois que vous devez faire très attention lorsque vous traitez du contenu encadré provenant de la même origine que le parent. Si une page de 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 entièrement l'attribut de bac à sable.

Jouez dans votre bac à sable

Le bac à sable est disponible dans de nombreux navigateurs: Firefox 17 et versions ultérieures, IE 10 et versions ultérieures, et Chrome au moment de la rédaction de cet article (caniuse propose bien sûr un tableau de compatibilité à jour). L'application de l'attribut sandbox à la iframes que vous incluez vous permet d'accorder certains droits sur le contenu qu'ils affichent uniquement les droits nécessaires au bon fonctionnement du contenu. Vous pouvez ainsi réduire le risque associé à l'inclusion de contenu tiers, en plus de ce qui est déjà possible avec le Content Security Policy.

De plus, le bac à sable est une technique efficace pour réduire le risque qu'un pirate informatique astucieux puisse exploiter des failles dans votre propre code. En séparant une application monolithique en un ensemble de services en bac à sable, chacun étant responsable d'une petite partie de la fonctionnalité autonome, les pirates informatiques seront contraints de compromettre non seulement le contenu de cadres spécifiques, mais aussi leur contrôleur. C'est une tâche beaucoup plus difficile, en particulier parce que le contrôleur peut avoir une portée considérablement réduite. Vous pouvez consacrer vos efforts liés à la sécurité à l'audit de ce code si vous demandez au navigateur de vous aider pour le reste.

Cela ne veut pas dire 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 prise en charge du navigateur pour tous vos utilisateurs (si vous contrôlez les clients de vos utilisateurs, un environnement d'entreprise, par exemple, bravo !). Un jour peut-être… mais pour l'instant, le bac à sable est une autre couche de protection pour renforcer vos défenses. Il ne s'agit pas d'une défense complète sur laquelle vous pouvez vous appuyer uniquement. Les calques restent excellents. Je vous suggère d'utiliser celle-ci.

Documentation complémentaire

  • "Séparation des privilèges dans les applications HTML5" est un article intéressant qui traite de la conception d'un petit framework et de son application à trois applications HTML5 existantes.

  • Le bac à sable peut être encore plus flexible lorsqu'il est associé à deux autres nouveaux attributs iFrame: srcdoc et seamless. Le premier vous permet de remplir un frame avec du contenu sans les frais généraux d'une requête HTTP, et le second permet au style de s'appliquer au contenu encadré. Pour le moment, la compatibilité avec les navigateurs est assez médiocre (versions nocturnes de Chrome et WebKit), mais ce sera une combinaison intéressante à l'avenir. Vous pouvez, par exemple, insérer des commentaires dans le bac à sable sur un article via le code suivant:

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