From e1bc6064f955dfce92d046b29e99f233bc8a4c7d Mon Sep 17 00:00:00 2001
From: FroRaut <79215537+Froraut@users.noreply.github.com>
Date: Thu, 1 Oct 2026 21:16:56 +0530
Subject: [PATCH] fix(desktop): render X and Instagram embeds in isolated
iframes
The tweet and Instagram renderers injected the vendors' widgets.js/embed.js
into the app document, which also hosts the preload bridge
(window.hermesDesktop). A compromised or hostile vendor script would run with
full access to that bridge. Render both in a sandboxed cross-origin iframe
instead and size it from the embed page's own height messages, accepting
them only from the vendor origin and the frame's own window.
Hand rebuild of #129637 onto current main: keeps the LazyRenderer autoplay
signature, deletes the now-unused escape-html.ts.
Salvage of #129637.
---
.../assistant-ui/embeds/escape-html.ts | 3 -
.../embeds/providers/instagram.ts | 2 +-
.../assistant-ui/embeds/providers/types.ts | 2 +-
.../assistant-ui/embeds/social-embed.test.tsx | 105 ++++++++++
.../assistant-ui/embeds/social-embed.tsx | 185 ++++++++----------
.../assistant-ui/embeds/url-embed.tsx | 5 +-
6 files changed, 197 insertions(+), 105 deletions(-)
delete mode 100644 apps/desktop/src/components/assistant-ui/embeds/escape-html.ts
create mode 100644 apps/desktop/src/components/assistant-ui/embeds/social-embed.test.tsx
diff --git a/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts b/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts
deleted file mode 100644
index c8545bca52..0000000000
--- a/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function escapeHtml(value: string): string {
- return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
-}
diff --git a/apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts b/apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts
index 3d45577a39..859c91d636 100644
--- a/apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts
+++ b/apps/desktop/src/components/assistant-ui/embeds/providers/instagram.ts
@@ -14,7 +14,7 @@ export const instagram: EmbedMatcher = url => {
return {
embedUrl: `https://www.instagram.com/${type}/${code}/embed`,
- // Placeholder height for content-visibility; embed.js self-sizes in-document.
+ // Placeholder height until the embed page reports its own (social-embed.tsx).
height: 450,
id: `instagram:${code}`,
label: 'Instagram',
diff --git a/apps/desktop/src/components/assistant-ui/embeds/providers/types.ts b/apps/desktop/src/components/assistant-ui/embeds/providers/types.ts
index b2735027a6..6090d42799 100644
--- a/apps/desktop/src/components/assistant-ui/embeds/providers/types.ts
+++ b/apps/desktop/src/components/assistant-ui/embeds/providers/types.ts
@@ -35,7 +35,7 @@ export interface FrameEmbed extends BaseEmbed {
renderer: 'frame'
}
-/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */
+/** Twitter/X: the iframe URL is built from the id and theme (see social-embed.tsx). */
export interface TweetEmbed extends BaseEmbed {
renderer: 'tweet'
tweetId: string
diff --git a/apps/desktop/src/components/assistant-ui/embeds/social-embed.test.tsx b/apps/desktop/src/components/assistant-ui/embeds/social-embed.test.tsx
new file mode 100644
index 0000000000..2b2dcd53d3
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/embeds/social-embed.test.tsx
@@ -0,0 +1,105 @@
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { detectEmbed } from './providers'
+import SocialEmbedRenderer, { reportedHeight } from './social-embed'
+
+afterEach(() => {
+ cleanup()
+ globalThis.document.documentElement.classList.remove('dark')
+})
+
+function renderEmbed(url: string) {
+ const descriptor = detectEmbed(url)
+
+ if (!descriptor) {
+ throw new Error(`expected an embed for ${url}`)
+ }
+
+ const { container } = render()
+ // Checked before the frame lookup so a script injection fails loudly.
+ expect(globalThis.document.querySelectorAll('script')).toHaveLength(0)
+ const frame = container.querySelector('iframe')
+
+ if (!frame) {
+ throw new Error('expected an iframe')
+ }
+
+ return frame
+}
+
+function post(frame: HTMLIFrameElement, origin: string, data: unknown, source = frame.contentWindow) {
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', { data, origin, source }))
+ })
+}
+
+describe('SocialEmbedRenderer', () => {
+ // The app document holds the preload bridge (window.hermesDesktop), so no
+ // vendor script may ever be loaded into it.
+ it.each([
+ ['https://x.com/jack/status/20', 'https://platform.twitter.com/embed/Tweet.html?id=20&theme=light&dnt=true'],
+ ['https://www.instagram.com/p/CabcDEF123/', 'https://www.instagram.com/p/CabcDEF123/embed'],
+ ['https://www.instagram.com/reel/CabcDEF123/', 'https://www.instagram.com/reel/CabcDEF123/embed']
+ ])('renders %s in a sandboxed cross-origin iframe without injecting a script', (url, src) => {
+ const frame = renderEmbed(url)
+
+ expect(frame.getAttribute('src')).toBe(src)
+ expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-same-origin')
+ expect(frame.getAttribute('referrerpolicy')).toBe('strict-origin-when-cross-origin')
+ })
+
+ it('passes the app theme to the tweet frame', () => {
+ globalThis.document.documentElement.classList.add('dark')
+
+ expect(new URL(renderEmbed('https://twitter.com/jack/status/20').src).searchParams.get('theme')).toBe('dark')
+ })
+
+ it("sizes the tweet frame from X's resize message", () => {
+ const frame = renderEmbed('https://x.com/jack/status/20')
+
+ post(frame, 'https://platform.twitter.com', {
+ 'twttr.embed': {
+ id: 'embed-0',
+ jsonrpc: '2.0',
+ method: 'twttr.private.resize',
+ params: [{ height: 225, width: 480 }]
+ }
+ })
+
+ expect(frame.style.height).toBe('225px')
+ })
+
+ it('sizes the Instagram frame from its MEASURE message', () => {
+ const frame = renderEmbed('https://www.instagram.com/p/CabcDEF123/')
+
+ expect(frame.style.height).toBe('450px')
+ post(frame, 'https://www.instagram.com', JSON.stringify({ details: { height: 608 }, type: 'MEASURE' }))
+
+ expect(frame.style.height).toBe('608px')
+ })
+
+ it('ignores height messages from another origin or another window', () => {
+ const frame = renderEmbed('https://www.instagram.com/p/CabcDEF123/')
+ const measure = JSON.stringify({ details: { height: 999 }, type: 'MEASURE' })
+
+ post(frame, 'https://platform.twitter.com', measure)
+ post(frame, 'https://www.instagram.com', measure, window)
+
+ expect(frame.style.height).toBe('450px')
+ })
+})
+
+describe('reportedHeight', () => {
+ it('reads only a positive finite height', () => {
+ expect(reportedHeight('{"type":"MEASURE","details":{"height":608.2}}')).toBe(609)
+ expect(reportedHeight({ 'twttr.embed': { method: 'twttr.private.resize', params: [{ height: 225 }] } })).toBe(225)
+ expect(
+ reportedHeight({ 'twttr.embed': { method: 'twttr.private.rendered', params: [{ height: 225 }] } })
+ ).toBeNull()
+ expect(reportedHeight('{"type":"MEASURE","details":{"height":"9e9"}}')).toBeNull()
+ expect(reportedHeight({ type: 'MEASURE', details: { height: -1 } })).toBeNull()
+ expect(reportedHeight('not json')).toBeNull()
+ expect(reportedHeight(null)).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
index 483aad6523..1813305abf 100644
--- a/apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
+++ b/apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
@@ -1,131 +1,120 @@
'use client'
-import { useEffect, useRef } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
-import { escapeHtml } from './escape-html'
import type { EmbedDescriptor } from './providers/types'
import { useIsDark } from './use-is-dark'
-// The provider embed scripts need a REAL origin to run (they touch
-// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
-// render the official blockquote in this document and let the script swap it for
-// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
-// the scripts silently bail (white / 2px). The container is height:auto, so it
-// grows to whatever the provider renders. No measuring, no forced height.
-type EmbedWindow = Window &
- typeof globalThis & {
- instgrm?: { Embeds?: { process?: () => void } }
- twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
- }
+// X and Instagram render in the provider's own cross-origin iframe, like every
+// other embed. Their widget scripts (widgets.js / embed.js) must never run in
+// this document: it is the privileged app window, and any script here can call
+// the preload bridge (window.hermesDesktop: terminal, files, backend API). The
+// sandbox keeps only what the embed pages need (scripts, and their own
+// origin's cookies and storage) and drops top navigation, popups (main's
+// window-open policy denies them anyway), forms and modals. The frame is
+// cross-origin, so allow-same-origin gives it nothing of ours, and Electron
+// injects no preload into subframes.
+export const SOCIAL_FRAME_SANDBOX = 'allow-scripts allow-same-origin'
-const SCRIPT: Record = {
- instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
- tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
- twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
+const FRAME_ORIGIN: Record = {
+ instagram: 'https://www.instagram.com',
+ twitter: 'https://platform.twitter.com'
}
-const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
+// Placeholder until the embed page reports its rendered height.
+const DEFAULT_HEIGHT = 320
-function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
- const url = escapeHtml(descriptor.sourceUrl)
+export function socialFrameSrc(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
+ if (descriptor.renderer !== 'tweet') {
+ return descriptor.embedUrl
+ }
- switch (descriptor.provider) {
- case 'instagram':
- return ``
- case 'tiktok': {
- const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
+ const url = new URL('https://platform.twitter.com/embed/Tweet.html')
- return ``
+ url.searchParams.set('id', descriptor.tweetId)
+ url.searchParams.set('theme', theme)
+ url.searchParams.set('dnt', 'true')
+
+ return url.toString()
+}
+
+interface EmbedMessage {
+ details?: { height?: unknown }
+ type?: unknown
+ 'twttr.embed'?: { method?: unknown; params?: { height?: unknown }[] }
+}
+
+// Both embed pages post their rendered height to the parent, the messages their
+// own widget scripts listen for: X sends {"twttr.embed": {method:
+// "twttr.private.resize", params: [{height}]}}, Instagram the JSON string
+// {"type": "MEASURE", "details": {height}}. Only that number is read.
+export function reportedHeight(data: unknown): number | null {
+ let message = data
+
+ if (typeof message === 'string') {
+ try {
+ message = JSON.parse(message)
+ } catch {
+ return null
}
-
- case 'twitter':
- // data-chrome="transparent" drops the card background so the themed page
- // shows through instead of a white box.
- return ``
-
- default:
- return ''
- }
-}
-
-function loadScript(provider: string): Promise {
- const { id, src } = SCRIPT[provider]
-
- // TikTok exposes no re-process API; its script rescans the document each time
- // it runs, so we re-inject it. The others are loaded once and reused.
- if (provider === 'tiktok') {
- document.getElementById(id)?.remove()
- } else if (document.getElementById(id)) {
- return Promise.resolve()
}
- return new Promise(resolve => {
- const script = document.createElement('script')
-
- script.async = true
- script.id = id
- script.onload = () => resolve()
- script.onerror = () => resolve()
- script.src = src
- document.body.appendChild(script)
- })
-}
-
-function processEmbed(provider: string, container: HTMLElement): void {
- const win = window as EmbedWindow
-
- if (provider === 'instagram') {
- win.instgrm?.Embeds?.process?.()
- } else if (provider === 'twitter') {
- win.twttr?.widgets?.load?.(container)
+ if (!message || typeof message !== 'object') {
+ return null
}
- // TikTok auto-scans on (re)injection — no manual process call.
+
+ const { details, type, 'twttr.embed': tweet } = message as EmbedMessage
+
+ const height =
+ tweet?.method === 'twttr.private.resize'
+ ? tweet.params?.[0]?.height
+ : type === 'MEASURE'
+ ? details?.height
+ : undefined
+
+ return typeof height === 'number' && Number.isFinite(height) && height > 0 ? Math.ceil(height) : null
}
export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
const isDark = useIsDark()
- const ref = useRef(null)
+ const ref = useRef(null)
+ const [height, setHeight] = useState(descriptor.height ?? DEFAULT_HEIGHT)
+ const src = useMemo(() => socialFrameSrc(descriptor, isDark ? 'dark' : 'light'), [descriptor, isDark])
useEffect(() => {
- const container = ref.current
-
- if (!container) {
- return
- }
-
- let cancelled = false
- const timers: number[] = []
-
- container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
-
- void loadScript(descriptor.provider).then(() => {
- // The script renders asynchronously; nudge a few times so the embed
- // settles whether the script was cached or freshly fetched.
- for (const delay of PROCESS_DELAYS_MS) {
- timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
- }
- })
-
- return () => {
- cancelled = true
-
- for (const timer of timers) {
- clearTimeout(timer)
+ const onMessage = (event: MessageEvent) => {
+ if (event.source !== ref.current?.contentWindow || event.origin !== FRAME_ORIGIN[descriptor.provider]) {
+ return
}
- container.innerHTML = ''
+ const next = reportedHeight(event.data)
+
+ if (next) {
+ setHeight(next)
+ }
}
- }, [descriptor, isDark])
+
+ window.addEventListener('message', onMessage)
+
+ return () => window.removeEventListener('message', onMessage)
+ }, [descriptor.provider])
// The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
// resolved scheme differs from ours, the browser paints an opaque (white)
- // Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
- // to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
- // then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
+ // Canvas behind it. The embed pages resolve to `light`, so the iframe is
+ // forced to `light` to match; theme=dark still gives the dark tweet card.
return (
-
)
}
diff --git a/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx
index 1560c3f11f..35996b0aa3 100644
--- a/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx
+++ b/apps/desktop/src/components/assistant-ui/embeds/url-embed.tsx
@@ -26,8 +26,9 @@ function intrinsicHeight(descriptor: EmbedDescriptor): number {
}
function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
- // X and Instagram load their official blockquote script in-document. The tweet
- // check also narrows the union to FrameEmbed for the iframe renderers below.
+ // X and Instagram get a sandboxed iframe that sizes itself from the embed
+ // page's height messages. The tweet check also narrows the union to
+ // FrameEmbed for the iframe renderers below.
if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
return
}