Why Third-Party Embeds in the Desktop App Have to Be Sandboxed

You paste a YouTube link into the desktop app’s chat, click it, and the player area answers with a single line: “Video player configuration error.” Swap it for an X or Instagram post and it renders fine — but here is the thing you probably never stopped to consider: that post’s rendering script is running in a window that can start a terminal and read your files. Two small aggravations, one underlying problem seen from two sides. The desktop app was inviting outside web content into the one window that holds every privilege the app has. This post explains what went wrong, how it was fixed, and what — if anything — you need to do.
Third-party scripts running in the window that drives your terminal
Start with why that window is sensitive. The desktop renderer carries a preload bridge called window.hermesDesktop — the pipe through which the front end reaches the machine: terminal, files, backend API. That means any script that manages to execute in the renderer can reach across and call the bridge, including terminal.start and terminal.write. Opening a terminal out of thin air and typing into it feels, to you, exactly like typing on your own keyboard.
Before the fix, apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx took the direct route: it appended platform.twitter.com/widgets.js and www.instagram.com/embed.js straight into the main renderer document. Those scripts come from third-party servers. Anyone who can tamper with them in transit — or simply serve a modified copy — writes code into a window that holds every privilege.
This is the code fix for the public advisory GHSA-5rwg-hf29-9hm8 (CWE-829, “inclusion of functionality from an untrusted source”). The issue was found and reported by @Froraut, and the fix landed in PR #130939.
Fix one: X and Instagram move into sandboxed iframes
The idea is one sentence long: if the script cannot be trusted, do not let it through your door — let it run on the provider’s own turf instead.
X and Instagram now render inside a cross-origin sandboxed iframe carrying sandbox="allow-scripts allow-same-origin". That keeps exactly what an embed page needs — the ability to run scripts and to use its own origin’s cookies and storage — and drops top-level navigation, popups, forms and modals. One detail is easy to misread: allow-same-origin sounds alarming, but because the iframe is always cross-origin, it grants the frame its own origin and nothing of the app’s. Electron injects no preload into subframes either, so the dangerous bridge is simply not in there.
The two providers use different URLs. X loads https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true — the post id, a theme that follows the app’s light/dark state, and dnt=true. Instagram keeps its existing embed URL.
One more detail is worth calling out on its own: the embed’s height only arrives through message events, and the parent accepts one only when two conditions hold at once — event.source is exactly that iframe’s contentWindow, proving the message came from this frame, and event.origin is exactly the matching provider domain. The height value itself must be a finite positive number; anything else is discarded. That way, another page or script cannot forge a “set the height to this” message and interfere. With the switch complete, the old escape-html.ts was deleted, because nothing needs to be injected into the app document anymore.
Fix two: YouTube plays through a local loopback page
YouTube is a different problem — not security, but “it will not play.”
The packaged desktop renderer is a file:// page, and YouTube insists that the embedding page have an http(s) origin matching the player’s origin parameter, or it fails with error 153 (a video player configuration error). Add the fact that modern Electron drops the stamped Referer header, and a file:// page simply cannot host the player itself.
The fix starts a small loopback host in the main process (apps/desktop/electron/embed-host.ts). It listens on a random 127.0.0.1 port (port: 0, chosen by the OS) and serves one tiny wrapper page per video. It recognizes exactly one path shape, /youtube/<11-character video id>. It forwards only three numeric parameters — start, rel, modestbranding — each validated with ^\d+$ so anything non-numeric is ignored, and it sets the player’s origin so the check passes.
The host itself is boxed in tightly:
- It only answers requests whose Host header is its own, so a page that DNS-rebinds onto the port gets nothing.
- Responses carry
Cache-Control: no-storeandX-Content-Type-Options: nosniff. - A CSP locks it down further:
default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline'allows framing the YouTube player and nothing else.
Crucially, the renderer stays on file://. Its origin, localStorage, settings and backend connections do not move; only the wrapper page gets an http origin. This replaces an earlier attempt, #127201, which tried to relocate the entire renderer onto a loopback origin — and reset users’ settings on update and got rejected by remote backends as a new origin. It was withdrawn in #130852.
There is also a version mismatch you might hit: a new renderer paired with an old main process gets no getEmbedHostOrigin and falls back to embedding directly — the behavior you see today. Likewise, if the host fails to start, it falls back to a plain iframe. So this change never makes playback impossible for anyone; it just fixes what it can.
Fix three: a malformed path no longer crashes the main process
Not long after the loopback host shipped, a follow-up fix landed (PR #131015).
The bug lived in an unremarkable bit of parsing. When the request path was a malformed value such as // or //[, new URL('//', origin) would throw an uncaught TypeError: Invalid URL inside the http.createServer callback and take the whole main process down — crash-forensics records it as a crash, and the socket never gets an answer. Because the Host check runs first, any local process could trigger it just by sending a correct Host header.
The fix is small: youtubeWrapperFor() first checks URL.canParse, returns null when it cannot parse, and the request handler answers 404 instead of taking the process with it.
Three habits worth taking from this fix
Set the specific code aside; these three submissions hold lessons for anyone building a desktop or hybrid app.
One: hand out the smallest permission that works. The sandboxed iframe keeps only allow-scripts allow-same-origin and drops top navigation, popups, forms and modals; the loopback host’s CSP is a whitelist that opens only frame-src. Deny by default and allow on demand is far steadier than opening everything and patching later.
Two: trust only messages that pass a double check. Cross-origin iframes can only talk via postMessage, and any page can post to your window. So a parent must not act on a message just because it arrived — it has to confirm both “the source window is exactly this iframe” and “the message came from the expected provider origin,” and validate the value on top of that. All three conditions matter; miss one and you have handed an outside page a tampering surface.
Three: prefer the smallest local change over a whole-repo migration. #127201 targeted the same problem as #130975 — YouTube would not play — but it chose to move the entire renderer to a loopback origin, which dragged origin, storage and backend trust along for the ride, and it was reverted. The new approach adds one video-only host to the main process and leaves the renderer in place. Same problem, an order of magnitude less surface, and far cheaper to roll back.
Version status and what you need to do
Start with the part that has to be stated plainly: these three changes live only on the main branch right now and are in no release.
| Item | Status |
|---|---|
| Latest stable release | v0.21.5 (tag v2026.9.24, released 2026-09-24) |
| When the three changes merged | 2026-10-01 to 2026-10-02 |
| In a release yet | No, main only |
| Do you need to configure anything | No, it applies on upgrade |
What this means for you depends on how you installed:
- If you track main — running from source, or on the canary channel — your next
hermes updatecarries all three changes; - If you are on a stable release, you will get them with the next stable tag;
- Either way, there is no config change, no field to set. It is a set of pure fixes.
If you care about the security-adjacent knobs — approvals, secrets — the security command reference and the keychain encryption write-up are worth a look; they show the same principle in a broader context, and this embed isolation is that principle extended into the desktop renderer. For more desktop behavior, see the reverted chat “out of date” loop fix.
The takeaway
The desktop app’s convenience comes from holding the UI, the terminal and your files at once. That convenience also means any outside content invited in is standing next to the center of power. The three changes here — X and Instagram into a sandboxed iframe, YouTube through a local loopback wrapper, a malformed path that no longer crashes the main process — are all doing the same thing: move the untrusted part somewhere it cannot reach the core, and keep only the minimum capability it needs to do its job. It is on main only for now, but the direction is worth remembering.