Por que embeds de terceiros no app desktop precisam ser isolados em sandbox


Você cola um link do YouTube no chat do app desktop, clica nele, e a área do player responde com uma única linha: “Video player configuration error.” Troque por um post do X ou do Instagram e ele renderiza sem problema — mas aqui está o detalhe em que você provavelmente nunca parou para pensar: o script de renderização daquele post está rodando em uma janela que consegue iniciar um terminal e ler seus arquivos. Dois pequenos aborrecimentos, um mesmo problema subjacente visto de dois lados. O app desktop estava convidando conteúdo web externo para dentro da única janela que concentra todos os privilégios do app. Este post explica o que deu errado, como foi corrigido e o que — se é que há algo — você precisa fazer.

Scripts de terceiros rodando na janela que comanda seu terminal

Comece pelo porquê de essa janela ser sensível. O renderer do desktop carrega uma ponte de preload chamada window.hermesDesktop — o cano pelo qual o front end alcança a máquina: terminal, arquivos, API de backend. Isso significa que qualquer script que consiga executar no renderer pode atravessar e chamar a ponte, incluindo terminal.start e terminal.write. Abrir um terminal do nada e digitar nele parece, para você, exatamente como digitar no seu próprio teclado.

Antes da correção, apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx seguia o caminho direto: anexava platform.twitter.com/widgets.js e www.instagram.com/embed.js diretamente ao documento principal do renderer. Esses scripts vêm de servidores de terceiros. Qualquer um que consiga adulterá-los em trânsito — ou simplesmente servir uma cópia modificada — injeta código em uma janela que concentra todos os privilégios.

Esta é a correção de código para o advisory público GHSA-5rwg-hf29-9hm8 (CWE-829, “inclusion of functionality from an untrusted source”). O problema foi encontrado e reportado por @Froraut, e a correção chegou no PR #130939.

Correção um: X e Instagram passam para iframes isolados em sandbox

A ideia cabe em uma frase: se o script não é confiável, não o deixe passar pela sua porta — deixe-o rodar no terreno do próprio provider.

X e Instagram agora renderizam dentro de um iframe em sandbox cross-origin que carrega sandbox="allow-scripts allow-same-origin". Isso mantém exatamente o que uma página de embed precisa — a capacidade de rodar scripts e de usar os cookies e o armazenamento do próprio origin — e descarta navegação de nível superior, pop-ups, formulários e modais. Um detalhe é fácil de interpretar mal: allow-same-origin soa alarmante, mas, como o iframe é sempre cross-origin, ele concede ao frame o seu próprio origin e nada do app. O Electron também não injeta preload em subframes, então a ponte perigosa simplesmente não está lá dentro.

Os dois providers usam URLs diferentes. O X carrega https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true — o id do post, um tema que segue o estado claro/escuro do app e dnt=true. O Instagram mantém sua URL de embed existente.

Vale destacar mais um detalhe por si só: a altura do embed só chega por eventos message, e o pai aceita um apenas quando duas condições valem ao mesmo tempo — event.source é exatamente o contentWindow daquele iframe, provando que a mensagem veio deste frame, e event.origin é exatamente o domínio correspondente do provider. O valor da altura em si precisa ser um número positivo finito; qualquer outra coisa é descartada. Assim, outra página ou script não pode forjar uma mensagem do tipo “defina a altura como isto” e interferir. Com a troca concluída, o antigo escape-html.ts foi excluído, porque nada mais precisa ser injetado no documento do app.

Correção dois: o YouTube toca através de uma página loopback local

O YouTube é um problema diferente — não de segurança, mas de “não vai tocar”.

O renderer empacotado do desktop é uma página file://, e o YouTube exige que a página que faz o embed tenha um origin http(s) que corresponda ao parâmetro origin do player, ou ele falha com o erro 153 (um erro de configuração do player de vídeo). Some a isso o fato de o Electron moderno descartar o cabeçalho Referer estampado, e uma página file:// simplesmente não consegue hospedar o player por conta própria.

A correção inicia um pequeno host loopback no processo principal (apps/desktop/electron/embed-host.ts). Ele escuta em uma porta aleatória de 127.0.0.1 (port: 0, escolhida pelo sistema operacional) e serve uma pequena página wrapper por vídeo. Ele reconhece exatamente um formato de caminho, /youtube/<11-character video id>. Encaminha apenas três parâmetros numéricos — start, rel, modestbranding — cada um validado com ^\d+$, de modo que qualquer coisa não numérica é ignorada, e define o origin do player para que a verificação passe.

O próprio host está bem encurralado:

  • Só responde a requisições cujo cabeçalho Host seja o seu próprio, então uma página que faça DNS rebinding para a porta não obtém nada.
  • As respostas carregam Cache-Control: no-store e X-Content-Type-Options: nosniff.
  • Uma CSP o restringe ainda mais: default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline' permite enquadrar o player do YouTube e nada mais.

Crucialmente, o renderer permanece em file://. Seu origin, localStorage, configurações e conexões de backend não se movem; apenas a página wrapper ganha um origin http. Isso substitui uma tentativa anterior, a #127201, que tentou realocar todo o renderer para um origin loopback — e redefinia as configurações dos usuários na atualização e era rejeitada por backends remotos como um novo origin. Ela foi retirada na #130852.

Há também um descasamento de versão com que você pode esbarrar: um renderer novo combinado com um processo principal antigo não recebe getEmbedHostOrigin e cai de volta no embed direto — o comportamento que você vê hoje. Da mesma forma, se o host falhar ao iniciar, há fallback para um iframe simples. Então essa mudança nunca torna a reprodução impossível para ninguém; apenas corrige o que pode.

Correção três: um caminho malformado não derruba mais o processo principal

Pouco depois de o host loopback ser lançado, chegou uma correção de acompanhamento (PR #131015).

O bug morava em um trecho de parsing nada notável. Quando o caminho da requisição era um valor malformado como // ou //[, new URL('//', origin) lançava um TypeError: Invalid URL não capturado dentro do callback de http.createServer e derrubava todo o processo principal — a análise forense de crash registra como um crash, e o socket nunca recebe resposta. Como a verificação de Host roda primeiro, qualquer processo local podia disparar o problema apenas enviando um cabeçalho Host correto.

A correção é pequena: youtubeWrapperFor() verifica primeiro URL.canParse, retorna null quando não consegue analisar, e o handler de requisição responde 404 em vez de levar o processo junto.

Três hábitos que valem a pena tirar desta correção

Deixe o código específico de lado; essas três submissões trazem lições para quem quer que esteja construindo um app desktop ou híbrido.

Um: conceda a menor permissão que funcione. O iframe em sandbox mantém apenas allow-scripts allow-same-origin e descarta navegação de nível superior, pop-ups, formulários e modais; a CSP do host loopback é uma whitelist que abre somente frame-src. Negar por padrão e permitir sob demanda é muito mais estável do que abrir tudo e corrigir depois.

Dois: confie apenas em mensagens que passam por uma verificação dupla. Iframes cross-origin só conseguem conversar via postMessage, e qualquer página pode postar na sua janela. Então um pai não deve agir sobre uma mensagem só porque ela chegou — precisa confirmar tanto “a janela de origem é exatamente este iframe” quanto “a mensagem veio do origin de provider esperado”, e ainda validar o valor por cima disso. As três condições importam; erre uma e você terá entregado a uma página externa uma superfície de adulteração.

Três: prefira a menor mudança local a uma migração de repositório inteiro. A #127201 mirou o mesmo problema que a #130975 — o YouTube não tocava —, mas optou por mover todo o renderer para um origin loopback, o que arrastou junto origin, armazenamento e confiança do backend, e foi revertida. A nova abordagem adiciona um host só para vídeo ao processo principal e deixa o renderer onde está. Mesmo problema, uma ordem de magnitude menos superfície e muito mais barato de reverter.

Status de versão e o que você precisa fazer

Comece pela parte que precisa ser dita com clareza: essas três mudanças estão apenas na branch main agora e não estão em nenhum release.

Item Status
Release estável mais recente v0.21.5 (tag v2026.9.24, lançado em 2026-09-24)
Quando as três mudanças foram mescladas 2026-10-01 a 2026-10-02
Já em um release Não, somente na main
Você precisa configurar algo Não, aplica-se na atualização

O que isso significa para você depende de como você instalou:

  • Se você acompanha a main — rodando a partir do código-fonte ou no canal canary —, seu próximo hermes update traz as três mudanças;
  • Se você está em uma versão estável, vai recebê-las na próxima tag estável;
  • De qualquer forma, não há mudança de configuração nem campo a definir. É um conjunto de correções puras.

Se você se interessa pelos ajustes ligados à segurança — aprovações, secrets —, vale dar uma olhada na referência de comandos de segurança e no artigo sobre criptografia do keychain; eles mostram o mesmo princípio em um contexto mais amplo, e este isolamento de embed é esse princípio estendido ao renderer do desktop. Para mais comportamento do desktop, veja o fix revertido do loop “out of date” do chat.

A conclusão

A conveniência do app desktop vem de concentrar ao mesmo tempo a UI, o terminal e seus arquivos. Essa conveniência também significa que qualquer conteúdo externo convidado para dentro fica ao lado do centro do poder. As três mudanças aqui — X e Instagram em um iframe em sandbox, YouTube através de um wrapper loopback local, um caminho malformado que não derruba mais o processo principal — fazem todas a mesma coisa: mover a parte não confiável para algum lugar onde ela não alcance o núcleo e manter apenas a capacidade mínima de que ela precisa para fazer seu trabalho. Por enquanto está só na main, mas a direção vale a pena memorizar.