fix(iframe): [PKG-IFRAME-03] release failed setup and bind message peers

This commit is contained in:
Harvey Zhao committed 2026-09-13 00:03:37 +08:00
1 parent b8d03e7366
commit b8cdafedd1
23 files changed
+7940 -32

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
import { expect, test } from './fixtures.js'
const candidate = await iframeCandidate()
const old = (await iframeHistorical()).filter(item => ['published-artplayer-plugin-iframe.js', 'workspace.js'].includes(item.name))
const cases = ['same-origin', 'cross-origin', 'opaque-origin', 'redirect'].flatMap(relation => ['parent-guard', 'child-guard', 'malformed'].map(scenario => ({ relation, scenario, parent: candidate, child: candidate })))
if (process.env.ARTPLAYER_IFRAME_BOUNDARIES_ONLY !== '1') {
for (const historical of old) {
for (const relation of ['same-origin', 'cross-origin']) {
cases.push({ relation, scenario: 'mixed-parent', parent: candidate, child: historical })
cases.push({ relation, scenario: 'mixed-child', parent: historical, child: candidate })
}
}
}
for (const item of cases) {
test(`Iframe boundary ${item.relation}/${item.scenario}/${item.parent.name}/${item.child.name}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
const parentOrigin = new URL(page.url()).origin
const alternate = parentOrigin.replace('127.0.0.1', 'localhost')
const childOrigin = ['cross-origin', 'redirect'].includes(item.relation) ? alternate : parentOrigin
const eventOrigin = item.relation === 'opaque-origin' ? 'null' : childOrigin
const query = `?auto=${item.scenario === 'parent-guard' ? '0' : '1'}&global=${item.child.global}`
const childUrl = `${childOrigin}/test/iframe-boundary-child.html${query}`
const initialUrl = item.relation === 'redirect' ? `${parentOrigin}/test/iframe-boundary-redirect${query}` : childUrl
const redirects = []
page.on('response', (response) => {
if (response.url() === initialUrl && response.status() === 302)
redirects.push({ url: response.url(), status: response.status(), location: response.headers().location })
})
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: item.child.code }))
await page.route('**/iframe-boundary-foreign', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script>parent.postMessage({type:"foreign-ready"},"*")</script>' }))
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${item.parent.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
await page.evaluate(({ initialUrl, opaque }) => {
window.packets = []
window.states = {}
window.callbacks = []
const frame = document.createElement('iframe')
if (opaque)
frame.sandbox = 'allow-scripts'
frame.id = 'boundary-target'
document.body.append(frame)
window.targetFrame = frame
addEventListener('message', event => window.packets.push({ type: event.data?.type, data: event.data?.data, id: event.data?.id, fromChild: event.source === frame.contentWindow, origin: event.origin }))
window.tool = new window.IframeFactory({ iframe: frame, url: initialUrl })
window.tool.message(packet => window.callbacks.push(packet))
window.track = (key, promise) => {
window.states[key] = { status: 'pending' }
promise.then(value => window.states[key] = { status: 'resolved', value }, error => window.states[key] = { status: 'rejected', error: error.message })
}
}, { initialUrl, opaque: item.relation === 'opaque-origin' })
let result
try {
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-ready'))).toBe(true)
const child = page.frames().find(frame => frame.url() === childUrl)
expect(child, 'actual child document after any redirect').toBeTruthy()
if (item.relation === 'redirect')
expect(redirects).toEqual([{ url: initialUrl, status: 302, location: childUrl }])
if (item.scenario === 'parent-guard' || item.scenario === 'child-guard') {
await page.evaluate((url) => {
const frame = document.createElement('iframe')
frame.src = url
document.body.append(frame)
}, `${alternate}/iframe-boundary-foreign`)
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'foreign-ready'))).toBe(true)
const foreign = page.frames().find(frame => frame.url().endsWith('/iframe-boundary-foreign'))
if (item.scenario === 'parent-guard') {
await foreign.evaluate(() => parent.postMessage({ type: 'inject', data: 'forged handshake' }, '*'))
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.data === 'forged handshake'))).toBe(true)
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
expect(await page.evaluate(() => window.callbacks.some(packet => packet.data === 'forged handshake'))).toBe(false)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
const id = await page.evaluate(() => {
window.track('held', window.tool.postMessage({ type: 'hold' }))
return Number(Object.keys(window.tool.promises)[0])
})
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
await foreign.evaluate(id => parent.postMessage({ type: 'custom-result', data: 'forged response', id }, '*'), id)
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.data === 'forged response'))).toBe(true)
expect(await page.evaluate(() => window.states.held.status)).toBe('pending')
expect(await page.evaluate(() => window.callbacks.some(packet => packet.data === 'forged response'))).toBe(false)
await child.evaluate(() => window.Tool.postMessage({ type: 'custom-result', data: 'real response', id: window.held.id }))
await expect.poll(() => page.evaluate(() => window.states.held.status)).toBe('resolved')
result = await page.evaluate(() => ({ state: window.states.held, pending: Object.keys(window.tool.promises).length, rejectedOrigins: window.packets.filter(packet => packet.data === 'forged handshake' || packet.data === 'forged response').map(packet => ({ origin: packet.origin, fromChild: packet.fromChild })) }))
expect(result.state).toEqual({ status: 'resolved', value: 'real response' })
expect(result.pending).toBe(0)
expect(result.rejectedOrigins).toEqual([{ origin: alternate, fromChild: false }, { origin: alternate, fromChild: false }])
}
else {
await foreign.evaluate(() => parent.frames[0].postMessage({ type: 'commit', data: 'window.executions++; return "foreign executed"', id: 999 }, '*'))
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-observed' && packet.data.id === 999))).toBe(true)
expect(await child.evaluate(() => window.executions)).toBe(0)
expect(await page.evaluate(() => window.packets.some(packet => packet.data === 'foreign executed'))).toBe(false)
const value = await page.evaluate(() => window.tool.commit(() => {
window.executions++
return 11
}))
expect(value).toBe(11)
result = await child.evaluate(() => ({ executions: window.executions, foreignCommit: window.received.find(packet => packet.id === 999) }))
expect(result.executions).toBe(1)
expect(result.foreignCommit.fromParent).toBe(false)
expect(result.foreignCommit.origin).toBe(alternate)
}
}
else if (item.scenario === 'malformed') {
await child.evaluate(() => {
for (const payload of [null, false, 3, 'packet', {}, { type: 3 }])
parent.postMessage(payload, '*')
parent.postMessage({ type: 'fixture-malformed-complete' }, '*')
})
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-malformed-complete'))).toBe(true)
await page.evaluate(() => {
for (const payload of [null, false, 3, 'packet', {}, { type: 3 }])
window.targetFrame.contentWindow.postMessage(payload, '*')
})
await expect.poll(() => child.evaluate(() => window.received.length)).toBe(6)
const value = await page.evaluate(() => window.tool.commit(() => {
return 12
}))
expect(value).toBe(12)
result = await page.evaluate(() => ({ invalidCallbacks: window.callbacks.filter(packet => typeof packet.type !== 'string').length, pending: Object.keys(window.tool.promises).length }))
expect(result).toEqual({ invalidCallbacks: 0, pending: 0 })
}
else {
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
result = await page.evaluate(async () => {
const sync = await window.tool.commit(() => {
return 14
})
const async = await window.tool.commit((resolve) => {
setTimeout(() => resolve(15), 5)
})
return { sync, async, pending: Object.keys(window.tool.promises).length }
})
expect(result).toEqual({ sync: 14, async: 15, pending: 0 })
}
const packets = await page.evaluate(() => window.packets)
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === eventOrigin)).toBe(true)
await testInfo.attach('iframe-boundary', { contentType: 'application/json', body: JSON.stringify({ relation: item.relation, scenario: item.scenario, parent: { name: item.parent.name, sha256: hash(item.parent.code) }, child: { name: item.child.name, sha256: hash(item.child.code) }, parentOrigin, initialUrl, childUrl, eventOrigin, redirects, result, packets, scope: 'Actual window peers and native source/origin. Redirect is an HTTP 302 from the local test server; opaque peer is sandbox allow-scripts. Mixed-version rows cover normal wire compatibility only, not security of an unchanged historical peer. No player/media integration.' }) })
}
finally {
await page.evaluate(() => {
window.tool.destroy()
document.querySelectorAll('iframe').forEach(frame => frame.remove())
})
}
})
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<script src="/iframe-boundary-lib.js"></script>
<script>
const parameters = new URL(location.href).searchParams;
window.Tool = window[parameters.get('global')];
window.received = [];
window.executions = 0;
addEventListener('message', event => {
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin });
if (event.data?.type === 'hold') window.held = event.data;
parent.postMessage({ type: 'fixture-observed', data: { count: received.length, type: event.data?.type, id: event.data?.id } }, '*');
});
if (parameters.get('auto') !== '0') Tool.inject();
parent.postMessage({ type: 'fixture-ready' }, '*');
</script>
+5
View File
@@ -75,6 +75,7 @@ const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/jav
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
add('/test/iframe-boundary-child.html', fs.readFileSync(path.join(workspace, 'test/browser/iframe-boundary-child.html')), { kind: 'iframe-fixture', file: 'test/browser/iframe-boundary-child.html' })
function send(req, res, bytes, filename) {
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-store')
@@ -103,6 +104,10 @@ const server = http.createServer((req, res) => {
assert(['GET', 'HEAD'].includes(req.method), 'Unsupported method')
const url = new URL(req.url, `http://127.0.0.1:${port}`)
const caseId = url.searchParams.get('case')
if (url.pathname === '/test/iframe-boundary-redirect') {
res.writeHead(302, { Location: `http://localhost:${port}/test/iframe-boundary-child.html${url.search}` }).end()
return
}
if (url.pathname === '/test/requests.json') {
send(req, res, Buffer.from(JSON.stringify(requests.get(caseId) || [])), 'requests.json')
return
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Real source compiled into an isolated window for ownership and protocol boundaries.
import test from 'node:test'
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
const implementation = await iframeCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function setup() {
const env = iframeEnvironment(implementation)
const frame = new env.Frame()
const instance = new env.Factory({ iframe: frame, url: '/first' })
return { ...env, frame, instance }
}
function observe(promise) {
const state = { status: 'pending' }
promise.then(value => Object.assign(state, { status: 'resolved', value }), error => Object.assign(state, { status: 'rejected', error: error.message }))
return state
}
test('Iframe source setter failure releases its bound listener and preserves the exact setup error', () => {
const env = iframeEnvironment(implementation)
const frame = new env.Frame()
const failure = new Error('src assignment rejected')
Object.defineProperty(frame, 'src', {
get() { return '/' },
set() {
throw failure
},
})
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
assert.equal(env.handlers.get('message').size, 0)
assert.equal(env.timers.size, 0)
})
test('Iframe partial listener acquisition failure releases ownership and keeps the first error', () => {
const env = iframeEnvironment(implementation)
const add = env.box.addEventListener
const failure = new Error('registration rejected after acquiring')
env.box.addEventListener = (...args) => {
add(...args)
throw failure
}
assert.throws(() => new env.Factory({ iframe: new env.Frame(), url: '/' }), error => error === failure)
assert.equal(env.handlers.get('message').size, 0)
assert.equal(env.order.includes('src'), false)
})
test('Iframe setup keeps the source error when releasing a partially constructed instance also throws', () => {
const env = iframeEnvironment(implementation)
const frame = new env.Frame()
const failure = new Error('original source error')
Object.defineProperty(frame, 'src', {
get() { return '/' },
set() {
throw failure
},
})
const remove = env.box.removeEventListener
let removals = 0
env.box.removeEventListener = (...args) => {
removals++
remove(...args)
throw new Error('secondary release error')
}
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
assert.equal(removals, 1)
assert.equal(env.handlers.get('message').size, 0)
})
test('Iframe destroy cancels requests even if listener release throws and does not repeat release', async () => {
const env = setup()
const state = observe(env.instance.postMessage({ type: 'waiting' }))
const failure = new Error('listener release failure')
const remove = env.box.removeEventListener
let removals = 0
env.box.removeEventListener = (...args) => {
removals++
remove(...args)
throw failure
}
assert.throws(() => env.instance.destroy(), error => error === failure)
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
assert.equal(env.timers.size, 0)
assert.equal(env.handlers.get('message').size, 0)
assert.doesNotThrow(() => env.instance.destroy())
assert.equal(removals, 1)
})
for (const injected of [false, true]) {
test(`Iframe failed construction cancels a reentrant ${injected ? 'sent' : 'waiting'} request`, async () => {
const env = iframeEnvironment(implementation)
const frame = new env.Frame()
const failure = new Error('source setter failed after reentry')
const receive = env.Factory.prototype.onMessage
let state
let partial
env.Factory.prototype.onMessage = function (event) {
partial = this
receive.call(this, event)
state = observe(this.postMessage({ type: 'query' }))
}
Object.defineProperty(frame, 'src', {
get() { return '/' },
set() {
env.dispatch({ type: injected ? 'inject' : 'custom' }, frame.contentWindow)
throw failure
},
})
assert.throws(() => new env.Factory({ iframe: frame, url: '/' }), error => error === failure)
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
assert.equal(partial.destroyed, true)
assert.equal(env.timers.size, 0)
assert.equal(Object.keys(partial.promises).length, 0)
assert.equal(env.handlers.get('message').size, 0)
})
}
test('Iframe parent rejects foreign handshakes, responses and callbacks but accepts its selected cross-origin peer', async () => {
const env = setup()
const seen = []
env.instance.message(packet => seen.push(packet.type))
env.dispatch({ type: 'inject' }, {})
assert.equal(env.instance.injected, false)
assert.deepEqual(seen, [])
env.dispatch({ type: 'inject' }, env.frame.contentWindow, 'https://redirected.example')
const state = observe(env.instance.postMessage({ type: 'query' }))
env.dispatch({ type: 'custom-result', data: 'forged', id: 1234 }, {})
await flush()
assert.deepEqual(state, { status: 'pending' })
assert.deepEqual(seen, ['inject'])
env.dispatch({ type: 'custom-result', data: 'real', id: 1234 }, env.frame.contentWindow, 'null')
await flush()
assert.deepEqual(state, { status: 'resolved', value: 'real' })
assert.deepEqual(seen, ['inject', 'custom-result'])
env.instance.destroy()
})
test('Iframe child rejects a foreign executable command and accepts only its actual parent peer', async () => {
const env = iframeEnvironment(implementation, true)
env.box.executions = 0
const data = { type: 'commit', data: 'window.executions++; return 7', id: 17 }
await env.Factory.onMessage({ data, source: {}, origin: 'https://sibling.example' })
assert.equal(env.box.executions, 0)
assert.equal(env.sent.length, 0)
await env.Factory.onMessage({ data, source: env.box.parent, origin: 'https://parent.example' })
assert.equal(env.box.executions, 1)
assert.equal(env.sent[0].packet.data, 7)
})
for (const data of [null, undefined, false, 3, 'message', {}, { type: null }, { type: 3 }]) {
test(`Iframe receivers ignore malformed payload ${JSON.stringify(data)} without callbacks or child rejection`, async () => {
const env = setup()
let calls = 0
env.instance.message(() => calls++)
assert.doesNotThrow(() => env.dispatch(data, env.frame.contentWindow))
assert.equal(calls, 0)
assert.equal(env.instance.injected, false)
const child = iframeEnvironment(implementation, true)
await assert.doesNotReject(child.Factory.onMessage({ data, source: child.box.parent }))
assert.equal(child.sent.length, 0)
env.instance.destroy()
})
}
test('Iframe public receivers keep source-less and null-source direct invocation as a local API', async () => {
const env = setup()
env.instance.onMessage({ data: { type: 'inject' } })
const promise = env.instance.postMessage({ type: 'query' })
env.instance.onMessage({ data: { type: 'custom', data: 9, id: 1234 }, source: null })
assert.equal(await promise, 9)
const child = iframeEnvironment(implementation, true)
await child.Factory.onMessage({ data: { type: 'commit', data: 'return 8', id: 9 }, source: null })
assert.equal(child.sent[0].packet.data, 8)
env.instance.destroy()
})
test('Iframe trusted protocol retains generic message types and child commit error serialization', async () => {
const env = setup()
const seen = []
env.instance.message(packet => seen.push({ ...packet }))
env.dispatch({ type: '', data: 'custom empty type' }, env.frame.contentWindow)
assert.deepEqual(seen, [{ type: '', data: 'custom empty type' }])
const child = iframeEnvironment(implementation, true)
await assert.rejects(child.Factory.onMessage({ data: { type: 'commit', data: 3, id: 7 }, source: child.box.parent }), error => error.name === 'TypeError')
assert.equal(child.sent[0].packet.type, 'error')
assert.equal(child.sent[0].packet.id, 7)
env.instance.destroy()
})