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.
This commit is contained in:
FroRaut
2026-10-01 21:16:56 +05:30
committed by kshitij
parent 45871e100f
commit e1bc6064f9
6 changed files with 197 additions and 105 deletions

View File

@@ -1,3 +0,0 @@
export function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

View File

@@ -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',

View File

@@ -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

View File

@@ -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(<SocialEmbedRenderer descriptor={descriptor} />)
// 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()
})
})

View File

@@ -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<string, { id: string; src: string }> = {
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<string, string> = {
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 `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
case 'tiktok': {
const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
const url = new URL('https://platform.twitter.com/embed/Tweet.html')
return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
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 `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
default:
return ''
}
}
function loadScript(provider: string): Promise<void> {
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<HTMLDivElement | null>(null)
const ref = useRef<HTMLIFrameElement | null>(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 (
<div
className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
<iframe
allowFullScreen
className="block w-full border-0 bg-transparent"
loading="lazy"
ref={ref}
referrerPolicy="strict-origin-when-cross-origin"
sandbox={SOCIAL_FRAME_SANDBOX}
scrolling="no"
src={src}
style={{ colorScheme: 'light', height }}
title={`${descriptor.label} embed`}
/>
)
}

View File

@@ -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 <SocialEmbedRenderer descriptor={descriptor} />
}