Pourquoi les embeds tiers dans l'application Desktop doivent être sandboxés


Vous collez un lien YouTube dans le chat de l’application desktop, vous cliquez, et la zone du lecteur répond par une seule ligne : “Video player configuration error.” Remplacez-le par une publication X ou Instagram et elle s’affiche correctement — mais voici la chose à laquelle vous ne vous êtes probablement jamais arrêté : le script de rendu de cette publication s’exécute dans une fenêtre qui peut lancer un terminal et lire vos fichiers. Deux petites contrariétés, un seul problème de fond vu sous deux angles. L’application desktop invitait du contenu web extérieur dans l’unique fenêtre qui détient tous les privilèges de l’application. Cet article explique ce qui a mal tourné, comment cela a été corrigé, et ce que — le cas échéant — vous devez faire.

Des scripts tiers qui s’exécutent dans la fenêtre qui pilote votre terminal

Commençons par pourquoi cette fenêtre est sensible. Le renderer desktop porte un pont de préchargement appelé window.hermesDesktop — le tuyau par lequel le front-end atteint la machine : terminal, fichiers, API backend. Cela signifie que tout script qui parvient à s’exécuter dans le renderer peut traverser et appeler le pont, y compris terminal.start et terminal.write. Ouvrir un terminal de toutes pièces et y taper ressemble, pour vous, exactement à taper sur votre propre clavier.

Avant le correctif, apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx prenait la voie directe : il ajoutait platform.twitter.com/widgets.js et www.instagram.com/embed.js directement dans le document du renderer principal. Ces scripts viennent de serveurs tiers. Quiconque peut les altérer en transit — ou simplement en servir une copie modifiée — écrit du code dans une fenêtre qui détient tous les privilèges.

C’est le correctif de code pour l’avis public GHSA-5rwg-hf29-9hm8 (CWE-829, “inclusion of functionality from an untrusted source”). Le problème a été trouvé et signalé par @Froraut, et le correctif est arrivé dans la PR #130939.

Correctif un : X et Instagram passent dans des iframes sandboxées

L’idée tient en une phrase : si le script ne peut pas être de confiance, ne le laissez pas franchir votre porte — laissez-le tourner sur le terrain du provider lui-même.

X et Instagram s’affichent désormais à l’intérieur d’une iframe sandboxée cross-origin portant sandbox="allow-scripts allow-same-origin". Cela conserve exactement ce dont une page d’embed a besoin — la capacité d’exécuter des scripts et d’utiliser les cookies et le stockage de sa propre origine — et abandonne la navigation de premier niveau, les popups, les formulaires et les modales. Un détail est facile à mal lire : allow-same-origin sonne alarmant, mais comme l’iframe est toujours cross-origin, il accorde à la frame sa propre origine et rien de celle de l’application. Electron n’injecte non plus aucun preload dans les sous-frames, le pont dangereux n’est donc tout simplement pas dedans.

Les deux providers utilisent des URL différentes. X charge https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true — l’id de la publication, un thème qui suit l’état clair/sombre de l’application, et dnt=true. Instagram garde son URL d’embed existante.

Un autre détail mérite d’être souligné à part : la hauteur de l’embed n’arrive que par des événements message, et le parent n’en accepte un que lorsque deux conditions sont réunies — event.source est exactement le contentWindow de cette iframe, ce qui prouve que le message vient de cette frame, et event.origin est exactement le domaine du provider correspondant. La valeur de hauteur elle-même doit être un nombre positif fini ; tout le reste est ignoré. Ainsi, une autre page ou un autre script ne peut pas forger un message “règle la hauteur à ceci” et interférer. L’interrupteur une fois basculé, l’ancien escape-html.ts a été supprimé, car plus rien n’a besoin d’être injecté dans le document de l’application.

Correctif deux : YouTube lit via une page loopback locale

YouTube est un problème différent — pas de sécurité, mais “ça ne lit pas”.

Le renderer desktop packagé est une page file://, et YouTube exige que la page d’intégration ait une origine http(s) correspondant au paramètre origin du lecteur, faute de quoi il échoue avec l’erreur 153 (une erreur de configuration du lecteur vidéo). Ajoutez le fait qu’Electron moderne supprime l’en-tête Referer estampillé, et une page file:// ne peut tout simplement pas héberger le lecteur elle-même.

Le correctif démarre un petit hôte loopback dans le processus principal (apps/desktop/electron/embed-host.ts). Il écoute sur un port aléatoire 127.0.0.1 (port: 0, choisi par l’OS) et sert une minuscule page d’encapsulation par vidéo. Il reconnaît exactement une forme de chemin, /youtube/<11-character video id>. Il ne transmet que trois paramètres numériques — start, rel, modestbranding — chacun validé avec ^\\d+$ de sorte que tout ce qui n’est pas numérique est ignoré, et il définit l’origin du lecteur pour que le contrôle passe.

L’hôte lui-même est enfermé étroitement :

  • Il ne répond qu’aux requêtes dont l’en-tête Host est le sien, une page qui se rebinde en DNS sur le port n’obtient donc rien.
  • Les réponses portent Cache-Control: no-store et X-Content-Type-Options: nosniff.
  • Une CSP le verrouille davantage : default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline' autorise l’encadrement du lecteur YouTube et rien d’autre.

Point crucial : le renderer reste sur file://. Son origine, son localStorage, ses réglages et ses connexions backend ne bougent pas ; seule la page d’encapsulation obtient une origine http. Cela remplace une tentative antérieure, #127201, qui cherchait à déplacer tout le renderer sur une origine loopback — et réinitialisait les réglages des utilisateurs lors de la mise à jour, et se faisait rejeter par les backends distants comme une nouvelle origine. Elle a été retirée dans #130852.

Il y a aussi un décalage de version que vous pourriez rencontrer : un nouveau renderer associé à un ancien processus principal n’obtient pas de getEmbedHostOrigin et retombe sur un embed direct — le comportement que vous voyez aujourd’hui. De même, si l’hôte ne démarre pas, il retombe sur une iframe ordinaire. Ce changement ne rend donc jamais la lecture impossible pour quiconque ; il corrige juste ce qu’il peut.

Correctif trois : un chemin malformé ne fait plus planter le processus principal

Peu après la livraison de l’hôte loopback, un correctif de suivi est arrivé (PR #131015).

Le bug vivait dans un morceau d’analyse sans éclat. Quand le chemin de la requête était une valeur malformée comme // ou //[, new URL('//', origin) levait un TypeError: Invalid URL non capturé à l’intérieur du callback de http.createServer et faisait tomber tout le processus principal — la forensique de crash l’enregistre comme un crash, et le socket ne reçoit jamais de réponse. Comme le contrôle du Host s’exécute en premier, n’importe quel processus local pouvait le déclencher rien qu’en envoyant un en-tête Host correct.

Le correctif est petit : youtubeWrapperFor() vérifie d’abord URL.canParse, renvoie null quand il ne peut pas analyser, et le gestionnaire de requêtes répond 404 au lieu d’emporter le processus avec lui.

Trois habitudes à retenir de ce correctif

Mettez le code précis de côté ; ces trois soumissions portent des leçons pour quiconque construit une application desktop ou hybride.

Un : n’accordez que la plus petite permission qui fonctionne. L’iframe sandboxée ne garde que allow-scripts allow-same-origin et abandonne la navigation de premier niveau, les popups, les formulaires et les modales ; la CSP de l’hôte loopback est une liste blanche qui n’ouvre que frame-src. Refuser par défaut et autoriser à la demande est bien plus stable qu’ouvrir tout et rapiécer ensuite.

Deux : ne faites confiance qu’aux messages qui passent un double contrôle. Les iframes cross-origin ne peuvent communiquer que via postMessage, et n’importe quelle page peut poster vers votre fenêtre. Un parent ne doit donc pas agir sur un message simplement parce qu’il est arrivé — il doit confirmer à la fois que “la fenêtre source est exactement cette iframe” et que “le message vient de l’origine du provider attendu”, puis valider la valeur par-dessus. Les trois conditions comptent ; manquez-en une et vous avez offert à une page extérieure une surface d’altération.

Trois : préférez le plus petit changement local à une migration de tout le dépôt. #127201 visait le même problème que #130975 — YouTube ne lisait pas — mais il a choisi de déplacer tout le renderer vers une origine loopback, ce qui entraînait dans son sillage l’origine, le stockage et la confiance des backends, et il a été annulé. La nouvelle approche ajoute un hôte réservé aux vidéos au processus principal et laisse le renderer en place. Même problème, un ordre de grandeur de surface en moins, et bien moins cher à annuler.

État des versions et ce que vous devez faire

Commençons par la partie qui doit être dite clairement : ces trois changements ne vivent que sur la branche main pour l’instant et ne sont dans aucune version.

Élément Statut
Dernière version stable v0.21.5 (tag v2026.9.24, publiée le 2026-09-24)
Date de fusion des trois changements 2026-10-01 à 2026-10-02
Déjà dans une version Non, main uniquement
Y a-t-il quelque chose à configurer Non, cela s’applique à la mise à niveau

Ce que cela signifie pour vous dépend de votre mode d’installation :

  • Si vous suivez main — exécution depuis les sources, ou sur le canal canary — votre prochain hermes update embarque les trois changements ;
  • Si vous êtes sur une version stable, vous les obtiendrez avec le prochain tag stable ;
  • Dans les deux cas, aucun changement de configuration, aucun champ à définir. C’est un ensemble de correctifs purs.

Si les réglages liés à la sécurité vous intéressent — approbations, secrets — la référence des commandes de sécurité et l’article sur le chiffrement du keychain valent un coup d’œil ; ils montrent le même principe dans un contexte plus large, et cet isolement des embeds est ce principe étendu au renderer desktop. Pour plus de comportements du desktop, voir le correctif annulé de la boucle “out of date” du chat.

À retenir

Le confort de l’application desktop vient de ce qu’elle détient à la fois l’UI, le terminal et vos fichiers. Ce confort signifie aussi que tout contenu extérieur invité à entrer se tient à côté du centre du pouvoir. Les trois changements ici — X et Instagram dans une iframe sandboxée, YouTube via une encapsulation loopback locale, un chemin malformé qui ne fait plus planter le processus principal — font tous la même chose : déplacer la partie non fiable là où elle ne peut pas atteindre le cœur, et ne lui garder que la capacité minimale nécessaire à son travail. C’est sur main uniquement pour l’instant, mais la direction vaut la peine d’être retenue.