Files
ArtPlayer/test/browser/iframe-boundaries.spec.js
T

152 lines
10 KiB
JavaScript

import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { iframeBrowserCandidate, iframeHistorical } from '../helpers/iframe.js'
import { expect, test } from './fixtures.js'
const candidate = await iframeBrowserCandidate()
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.filter(packet => !packet.internal).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), provenance: item.parent.provenance || null }, child: { name: item.child.name, sha256: hash(item.child.code), provenance: item.child.provenance || null }, 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())
})
}
})
}