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:
@@ -1,3 +0,0 @@
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user