Por qué los embeds de terceros en la app de escritorio tienen que ir en un sandbox

Pegas un enlace de YouTube en el chat de la app de escritorio, haces clic, y el área del reproductor responde con una sola línea: «Video player configuration error.» Cámbialo por un post de X o de Instagram y se renderiza sin problema; pero aquí está lo que probablemente nunca te paraste a considerar: el script de renderizado de ese post se está ejecutando en una ventana que puede abrir una terminal y leer tus archivos. Dos pequeñas molestias, un mismo problema de fondo visto desde dos lados. La app de escritorio estaba invitando contenido web externo a la única ventana que concentra todos los privilegios de la app. Este post explica qué salió mal, cómo se arregló y qué —si acaso— tienes que hacer tú.
Scripts de terceros corriendo en la ventana que maneja tu terminal
Empecemos por por qué esa ventana es sensible. El renderer de escritorio lleva un preload bridge llamado window.hermesDesktop: la tubería por la que el front end llega a la máquina: terminal, archivos, API de backend. Eso significa que cualquier script que consiga ejecutarse en el renderer puede cruzar y llamar al bridge, incluidos terminal.start y terminal.write. Abrir una terminal de la nada y escribir en ella, para ti, se siente exactamente igual que teclear en tu propio teclado.
Antes del fix, apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx tomaba la ruta directa: añadía platform.twitter.com/widgets.js y www.instagram.com/embed.js directamente al documento principal del renderer. Esos scripts vienen de servidores de terceros. Cualquiera que pueda manipularlos en tránsito —o simplemente servir una copia modificada— escribe código en una ventana que concentra todos los privilegios.
Este es el fix de código del aviso público GHSA-5rwg-hf29-9hm8 (CWE-829, «inclusión de funcionalidad de una fuente no confiable»). El problema lo encontró y reportó @Froraut, y el fix se fusionó en el PR #130939.
Fix uno: X e Instagram pasan a iframes en sandbox
La idea cabe en una frase: si el script no es de fiar, no lo dejes entrar por tu puerta; deja que corra en el terreno del propio proveedor.
X e Instagram ahora se renderizan dentro de un iframe cross-origin en sandbox con sandbox="allow-scripts allow-same-origin". Eso conserva justo lo que una página de embed necesita —la capacidad de ejecutar scripts y de usar las cookies y el almacenamiento de su propio origen— y elimina la navegación de nivel superior, los popups, los formularios y los modales. Un detalle es fácil de malinterpretar: allow-same-origin suena alarmante, pero como el iframe es siempre cross-origin, le concede a ese frame su propio origen y nada del de la app. Además, Electron no inyecta ningún preload en los subframes, así que el bridge peligroso sencillamente no está ahí.
Los dos proveedores usan URLs distintas. X carga https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true —el id del post, un theme que sigue el estado claro/oscuro de la app y dnt=true—. Instagram mantiene su URL de embed existente.
Otro detalle merece mención aparte: la altura del embed solo llega a través de eventos message, y el padre acepta uno únicamente cuando se cumplen dos condiciones a la vez: event.source es exactamente el contentWindow de ese iframe, lo que prueba que el mensaje vino de este frame, y event.origin es exactamente el dominio del proveedor correspondiente. El valor de la altura en sí tiene que ser un número positivo finito; cualquier otra cosa se descarta. Así, otra página o script no puede falsificar un mensaje de «pon la altura a este valor» e interferir. Con el cambio completo, el antiguo escape-html.ts se eliminó, porque ya no hace falta inyectar nada en el documento de la app.
Fix dos: YouTube reproduce a través de una página loopback local
YouTube es un problema distinto: no de seguridad, sino de «no se reproduce».
El renderer empaquetado de escritorio es una página file://, y YouTube insiste en que la página que embebe tenga un origen http(s) que coincida con el parámetro origin del reproductor, o falla con el error 153 (un error de configuración del reproductor de vídeo). Súmale que el Electron moderno elimina la cabecera Referer estampada, y una página file:// simplemente no puede alojar el reproductor por sí sola.
El fix arranca un host loopback pequeño en el proceso principal (apps/desktop/electron/embed-host.ts). Escucha en un puerto aleatorio de 127.0.0.1 (port: 0, elegido por el sistema operativo) y sirve una página wrapper diminuta por vídeo. Reconoce exactamente una forma de ruta, /youtube/<11-character video id>. Solo reenvía tres parámetros numéricos —start, rel, modestbranding—, cada uno validado con ^\d+$ para que se ignore cualquier cosa no numérica, y fija el origin del reproductor para que pase la comprobación.
El host está muy encajonado:
- Solo responde a peticiones cuyo Host header es el suyo, así que una página que haga DNS rebinding sobre el puerto no obtiene nada.
- Las respuestas llevan
Cache-Control: no-storeyX-Content-Type-Options: nosniff. - Una CSP lo cierra todavía más:
default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline'permite embeber el reproductor de YouTube y nada más.
Y lo crucial: el renderer se queda en file://. Su origen, localStorage, ajustes y conexiones de backend no se mueven; solo la página wrapper obtiene un origen http. Esto sustituye a un intento anterior, #127201, que trataba de reubicar todo el renderer en un origen loopback, y reseteaba los ajustes de los usuarios al actualizar y era rechazado por backends remotos como un origen nuevo. Se retiró en #130852.
También hay un desajuste de versiones con el que puedes toparte: un renderer nuevo emparejado con un proceso principal antiguo no recibe getEmbedHostOrigin y recurre a embeber directamente —el comportamiento que ves hoy—. Igualmente, si el host no arranca, recurre a un iframe simple. Así que este cambio nunca deja la reproducción imposible para nadie; solo arregla lo que puede.
Fix tres: una ruta malformada ya no tumba el proceso principal
Poco después de que se lanzara el host loopback, llegó un fix de seguimiento (PR #131015).
El bug vivía en un trozo de parseo nada llamativo. Cuando la ruta de la petición era un valor malformado como // o //[, new URL('//', origin) lanzaba un TypeError: Invalid URL no capturado dentro del callback de http.createServer y se llevaba por delante todo el proceso principal: los registros de crash-forensics lo anotan como un crash, y el socket nunca recibe respuesta. Como la comprobación de Host va primero, cualquier proceso local podía desencadenarlo con solo enviar un Host header correcto.
El fix es pequeño: youtubeWrapperFor() primero comprueba URL.canParse, devuelve null cuando no puede parsear, y el manejador de la petición responde 404 en lugar de llevarse el proceso consigo.
Tres hábitos que merece la pena sacar de este fix
Deja a un lado el código concreto; estas tres contribuciones guardan lecciones para cualquiera que construya una app de escritorio o híbrida.
Uno: concede el permiso más pequeño que funcione. El iframe en sandbox conserva solo allow-scripts allow-same-origin y elimina la navegación superior, los popups, los formularios y los modales; la CSP del host loopback es una whitelist que solo abre frame-src. Denegar por defecto y permitir bajo demanda es mucho más estable que abrir todo y parchear después.
Dos: confía solo en los mensajes que pasan un doble chequeo. Los iframes cross-origin solo pueden hablar mediante postMessage, y cualquier página puede enviar mensajes a tu ventana. Por eso un padre no debe actuar ante un mensaje solo porque haya llegado: tiene que confirmar tanto «la ventana origen es exactamente este iframe» como «el mensaje vino del origen del proveedor esperado», y validar además el valor. Las tres condiciones importan; si te falta una, le has entregado a una página externa una superficie de manipulación.
Tres: prefiere el cambio local más pequeño antes que migrar todo el repo. #127201 atacaba el mismo problema que #130975 —YouTube no se reproducía—, pero optó por mover todo el renderer a un origen loopback, lo que arrastraba consigo el origen, el almacenamiento y la confianza del backend, y se revirtió. El enfoque nuevo añade un host solo para vídeo al proceso principal y deja el renderer donde está. El mismo problema, un orden de magnitud menos de superficie y mucho más barato de revertir.
Estado de versión y qué tienes que hacer
Empecemos por la parte que hay que decir sin rodeos: estos tres cambios viven solo en la rama main ahora mismo y no están en ninguna release.
| Elemento | Estado |
|---|---|
| Última release estable | v0.21.5 (tag v2026.9.24, publicada el 2026-09-24) |
| Cuándo se fusionaron los tres cambios | 2026-10-01 a 2026-10-02 |
| ¿Ya en una release? | No, solo en main |
| ¿Tienes que configurar algo? | No, se aplica al actualizar |
Qué significa esto para ti depende de cómo instalaste:
- Si sigues main —corriendo desde el source o en el canal canary— tu siguiente
hermes updatetrae los tres cambios; - Si estás en una release estable, los tendrás con el próximo tag estable;
- En cualquier caso, no hay cambio de config ni campo que definir. Es un conjunto de fixes puros.
Si te interesan los botones relacionados con la seguridad —approvals, secrets—, la referencia del comando de seguridad y el artículo sobre el cifrado del keychain merecen un vistazo; muestran el mismo principio en un contexto más amplio, y este aislamiento de embeds es ese principio extendido al renderer de escritorio. Para más comportamiento del escritorio, mira el fix revertido del bucle «out of date» del chat.
La conclusión
La comodidad de la app de escritorio viene de tener a la vez la UI, la terminal y tus archivos. Esa comodidad también significa que cualquier contenido externo que se invite a entrar queda al lado del centro de poder. Los tres cambios de aquí —X e Instagram a un iframe en sandbox, YouTube a través de un wrapper loopback local, una ruta malformada que ya no tumba el proceso principal— hacen todos lo mismo: mover la parte no confiable a un sitio donde no pueda alcanzar el núcleo, y conservar solo la capacidad mínima que necesita para hacer su trabajo. Por ahora está solo en main, pero la dirección merece recordarse.