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 }