mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 00:56:16 -08:00
fix(iframe): [PKG-IFRAME-03] release failed setup and bind message peers
This commit is contained in:
1 parent
b8d03e7366
commit
b8cdafedd1
23 files changed
+7940
-32
No files matched your search
@@ -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())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user