Warum Third-Party-Embeds in der Desktop-App sandboxed sein müssen


Du fügst einen YouTube-Link in den Chat der Desktop-App ein, klickst darauf, und der Player-Bereich antwortet mit einer einzigen Zeile: „Video player configuration error.“ Tauschst du ihn gegen einen X- oder Instagram-Post, rendert er problemlos – aber hier ist die Sache, über die du wahrscheinlich nie nachgedacht hast: Das Rendering-Skript jenes Posts läuft in einem Fenster, das ein Terminal starten und deine Dateien lesen kann. Zwei kleine Ärgernisse, ein zugrunde liegendes Problem, von zwei Seiten betrachtet. Die Desktop-App lud fremden Web-Inhalt in genau das eine Fenster ein, das jedes Privileg der App besitzt. Dieser Beitrag erklärt, was schiefging, wie es behoben wurde und was – wenn überhaupt etwas – du tun musst.

Third-Party-Skripte laufen im Fenster, das dein Terminal steuert

Fangen wir damit an, warum dieses Fenster sensibel ist. Der Desktop-Renderer trägt eine Preload-Bridge namens window.hermesDesktop – die Leitung, über die das Frontend die Maschine erreicht: Terminal, Dateien, Backend-API. Das heißt, jedes Skript, das es schafft, im Renderer ausgeführt zu werden, kann hinüberreichen und die Bridge aufrufen, einschließlich terminal.start und terminal.write. Ein Terminal aus dem Nichts zu öffnen und hineinzutippen fühlt sich für dich genau an wie das Tippen auf deiner eigenen Tastatur.

Vor dem Fix nahm apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx den direkten Weg: Es hängte platform.twitter.com/widgets.js und www.instagram.com/embed.js direkt in das Haupt-Renderer-Dokument ein. Diese Skripte stammen von Third-Party-Servern. Wer sie unterwegs manipulieren kann – oder einfach eine veränderte Kopie ausliefert –, schreibt Code in ein Fenster, das jedes Privileg besitzt.

Dies ist der Code-Fix für die öffentliche Advisory GHSA-5rwg-hf29-9hm8 (CWE-829, „inclusion of functionality from an untrusted source“). Das Problem wurde von @Froraut gefunden und gemeldet, und der Fix landete in PR #130939.

Fix eins: X und Instagram wandern in sandboxed iframes

Die Idee ist einen Satz lang: Wenn dem Skript nicht zu trauen ist, lass es nicht durch deine Tür – lass es stattdessen auf dem eigenen Terrain des Anbieters laufen.

X und Instagram rendern jetzt in einem cross-origin sandboxed iframe mit sandbox="allow-scripts allow-same-origin". Das behält genau das, was eine Embed-Seite braucht – die Fähigkeit, Skripte auszuführen und die Cookies und den Storage der eigenen Origin zu nutzen – und lässt Top-Level-Navigation, Popups, Formulare und Modals fallen. Ein Detail ist leicht falsch zu lesen: allow-same-origin klingt alarmierend, aber weil das iframe immer cross-origin ist, gewährt es dem Frame seine eigene Origin und nichts von der App. Electron injiziert außerdem kein Preload in Subframes, also ist die gefährliche Bridge dort schlicht nicht vorhanden.

Die beiden Anbieter nutzen unterschiedliche URLs. X lädt https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true – die Post-ID, ein Theme, das dem Hell/Dunkel-Zustand der App folgt, und dnt=true. Instagram behält seine bestehende Embed-URL.

Ein weiteres Detail ist es wert, gesondert erwähnt zu werden: Die Höhe des Embeds kommt nur über message-Events an, und das Parent akzeptiert eines nur, wenn zwei Bedingungen gleichzeitig erfüllt sind – event.source ist genau das contentWindow dieses iframes, was beweist, dass die Nachricht von diesem Frame kam, und event.origin ist genau die passende Provider-Domain. Der Höhenwert selbst muss eine endliche positive Zahl sein; alles andere wird verworfen. So kann eine andere Seite oder ein anderes Skript keine „Setze die Höhe auf dies“-Nachricht fälschen und dazwischenfunken. Mit abgeschlossenem Umbau wurde die alte escape-html.ts gelöscht, weil nichts mehr in das App-Dokument injiziert werden muss.

Fix zwei: YouTube spielt über eine lokale Loopback-Seite

YouTube ist ein anderes Problem – nicht Sicherheit, sondern „es spielt nicht ab.“

Der gepackte Desktop-Renderer ist eine file://-Seite, und YouTube besteht darauf, dass die einbettende Seite eine http(s)-Origin hat, die zum origin-Parameter des Players passt, sonst schlägt es mit Fehler 153 fehl (ein Videoplayer-Konfigurationsfehler). Füge die Tatsache hinzu, dass modernes Electron den gestempelten Referer-Header fallen lässt, und eine file://-Seite kann den Player schlicht nicht selbst hosten.

Der Fix startet einen kleinen Loopback-Host im Main-Prozess (apps/desktop/electron/embed-host.ts). Er lauscht auf einem zufälligen 127.0.0.1-Port (port: 0, vom OS gewählt) und liefert pro Video eine winzige Wrapper-Seite aus. Er erkennt genau eine Pfadform, /youtube/<11-character video id>. Er reicht nur drei numerische Parameter weiter – start, rel, modestbranding –, jeweils validiert mit ^\d+$, sodass alles Nichtnumerische ignoriert wird, und er setzt die origin des Players, damit die Prüfung durchgeht.

Der Host selbst ist eng eingezäunt:

  • Er beantwortet nur Anfragen, deren Host-Header sein eigener ist, sodass eine Seite, die per DNS-Rebinding auf den Port zeigt, nichts bekommt.
  • Antworten tragen Cache-Control: no-store und X-Content-Type-Options: nosniff.
  • Eine CSP sperrt ihn weiter ab: default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline' erlaubt das Einbetten des YouTube-Players und sonst nichts.

Entscheidend: Der Renderer bleibt auf file://. Seine Origin, localStorage, Einstellungen und Backend-Verbindungen bewegen sich nicht; nur die Wrapper-Seite bekommt eine http-Origin. Dies ersetzt einen früheren Versuch, #127201, der den gesamten Renderer auf eine Loopback-Origin verlegen wollte – und beim Update die Einstellungen der Nutzer zurücksetzte und von Remote-Backends als neue Origin abgelehnt wurde. Er wurde in #130852 zurückgezogen.

Es gibt außerdem ein Versions-Mismatch, auf das du stoßen könntest: Ein neuer Renderer mit einem alten Main-Prozess bekommt kein getEmbedHostOrigin und fällt auf direktes Einbetten zurück – das Verhalten, das du heute siehst. Ebenso: Wenn der Host nicht startet, fällt es auf ein einfaches iframe zurück. Diese Änderung macht also für niemanden die Wiedergabe unmöglich; sie behebt nur, was sie kann.

Fix drei: Ein fehlerhafter Pfad bringt den Main-Prozess nicht mehr zum Absturz

Kurz nachdem der Loopback-Host ausgeliefert wurde, landete ein Folge-Fix (PR #131015).

Der Bug lebte in einem unscheinbaren Stück Parsing. Wenn der Request-Pfad ein fehlerhafter Wert wie // oder //[ war, warf new URL('//', origin) innerhalb des http.createServer-Callbacks einen unbehandelten TypeError: Invalid URL und riss den gesamten Main-Prozess mit – die Crash-Forensik verzeichnet es als Absturz, und der Socket bekommt nie eine Antwort. Weil die Host-Prüfung zuerst läuft, konnte jeder lokale Prozess ihn auslösen, indem er einfach einen korrekten Host-Header sendet.

Der Fix ist klein: youtubeWrapperFor() prüft zuerst URL.canParse, gibt null zurück, wenn es nicht parsen kann, und der Request-Handler antwortet mit 404, statt den Prozess mitzureißen.

Drei Gewohnheiten, die sich aus diesem Fix mitnehmen lassen

Lass den konkreten Code beiseite; diese drei Submissions bergen Lehren für jeden, der eine Desktop- oder Hybrid-App baut.

Eins: Gib die kleinste Berechtigung aus, die funktioniert. Das sandboxed iframe behält nur allow-scripts allow-same-origin und lässt Top-Navigation, Popups, Formulare und Modals fallen; die CSP des Loopback-Hosts ist eine Whitelist, die nur frame-src öffnet. Default-Deny und Allow-on-Demand ist weit stabiler, als alles zu öffnen und später zu flicken.

Zwei: Traue nur Nachrichten, die eine doppelte Prüfung bestehen. Cross-origin iframes können nur über postMessage sprechen, und jede Seite kann an dein Fenster posten. Ein Parent darf also nicht auf eine Nachricht reagieren, nur weil sie ankam – er muss sowohl bestätigen, dass „das Quellfenster genau dieses iframe ist“, als auch, dass „die Nachricht von der erwarteten Provider-Origin kam“, und darüber hinaus den Wert validieren. Alle drei Bedingungen zählen; verpasst du eine, hast du einer fremden Seite eine Manipulationsfläche übergeben.

Drei: Bevorzuge die kleinste lokale Änderung gegenüber einer Migration des ganzen Repos. #127201 zielte auf dasselbe Problem wie #130975 – YouTube spielte nicht ab –, entschied sich aber, den gesamten Renderer auf eine Loopback-Origin zu verschieben, was Origin, Storage und Backend-Vertrauen mitzog, und wurde zurückgerollt. Der neue Ansatz fügt einen reinen Video-Host zum Main-Prozess hinzu und lässt den Renderer an Ort und Stelle. Gleiches Problem, eine Größenordnung weniger Fläche und weit billiger zurückzurollen.

Versionsstatus und was du tun musst

Fangen wir mit dem Teil an, der klar gesagt werden muss: Diese drei Änderungen leben derzeit nur auf dem main-Branch und sind in keinem Release.

Punkt Status
Neuestes stabiles Release v0.21.5 (Tag v2026.9.24, veröffentlicht 2026-09-24)
Wann die drei Änderungen gemergt wurden 2026-10-01 bis 2026-10-02
Schon in einem Release Nein, nur main
Musst du etwas konfigurieren Nein, es greift beim Upgrade

Was das für dich bedeutet, hängt davon ab, wie du installiert hast:

  • Wenn du main trackst – aus dem Quellcode läufst oder auf dem Canary-Kanal –, bringt dein nächstes hermes update alle drei Änderungen mit;
  • Wenn du auf einem stabilen Release bist, bekommst du sie mit dem nächsten stabilen Tag;
  • So oder so gibt es keine Konfigurationsänderung, kein Feld zum Setzen. Es ist eine Reihe reiner Fixes.

Wenn dich die sicherheitsnahen Stellschrauben interessieren – Approvals, Secrets –, lohnt sich ein Blick in die Security-Befehlsreferenz und den Keychain-Verschlüsselungsartikel; sie zeigen dasselbe Prinzip in einem breiteren Kontext, und diese Embed-Isolation ist dieses Prinzip, erweitert in den Desktop-Renderer. Mehr Desktop-Verhalten findest du im zurückgerollten Chat-„out of date“-Loop-Fix.

Das Fazit

Der Komfort der Desktop-App kommt daher, dass sie UI, Terminal und deine Dateien auf einmal hält. Dieser Komfort bedeutet auch, dass jeglicher eingeladene Fremdinhalt direkt neben dem Machtzentrum steht. Die drei Änderungen hier – X und Instagram in ein sandboxed iframe, YouTube über einen lokalen Loopback-Wrapper, ein fehlerhafter Pfad, der den Main-Prozess nicht mehr zum Absturz bringt – tun alle dasselbe: den nicht vertrauenswürdigen Teil dorthin verschieben, wo er den Kern nicht erreichen kann, und nur die minimale Fähigkeit behalten, die er für seine Aufgabe braucht. Es ist vorerst nur auf main, aber die Richtung ist es wert, sich zu merken.