Skip to content

Commit b8a8617

Browse files
authored
Merge pull request #527 from webdriverio/fix/replay-script-isolation
Run no script from a replayed trace, in any player mode
2 parents ae77e80 + ceaab29 commit b8a8617

18 files changed

Lines changed: 641 additions & 34 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@wdio/devtools-app': patch
3+
'@wdio/devtools-backend': patch
4+
---
5+
6+
A replayed trace runs no script. The page a trace recorded is rendered in a sandboxed frame, scripts, inline event handlers, `srcdoc` and `javascript:` URLs are dropped from it on every update, not only the first render, and the dashboard and `show-trace` now send the same Content-Security-Policy as the hosted player. A trace someone else recorded can therefore be opened safely in any of them.

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ They import from `shared` and `core`, never from each other. They aren't importe
105105

106106
### Backend and app are framework-agnostic
107107

108-
`backend` and `app` import from `shared` (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. `backend` also imports `trace`, to write and read zips; `app` imports only `trace`'s reader, which the hosted player (`trace.webdriver.io`, built with `pnpm player:hosted`, deployed by Vercel from `vercel.json`) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (`app/src/controller/trace-parse.worker.ts`, which `script-src 'self'` already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. That build is `HOSTED_PLAYER` in `app/src/controller/constants.ts`; it never writes the trace to browser storage, and `vercel.json` is also what `vite preview` serves, so its CSP is checked locally before it ships. Several zips or a folder open as a run (`controller/trace-run.ts`), in the hosted player and in `show-trace`, which lists them at `TRACE_API.run` for the app to read: each zip is parsed once, one at a time, and its payload kept for when its row is picked. A zip records no test state, so a row's outcome is read from its rows (`utils/trace-summary.ts`: a failed action or step means failed, actions with none failed mean passed, no actions mean unknown); a jest `expect` that records no row can therefore fail a test whose trace reads as passed. Keeping every payload costs memory in proportion to the run; a reader that reads entries on demand would shrink each payload, which is why the run holds the reader's own payload rather than a cache of its own.
108+
`backend` and `app` import from `shared` (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. `backend` also imports `trace`, to write and read zips; `app` imports only `trace`'s reader, which the hosted player (`trace.webdriver.io`, built with `pnpm player:hosted`, deployed by Vercel from `vercel.json`) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (`app/src/controller/trace-parse.worker.ts`, which `script-src 'self'` already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. A trace is untrusted input, and its replay must run no script in any mode: the replay iframe is sandboxed (`allow-same-origin` only), `components/browser/replay-safety.ts` drops `<script>`, `on*`, `srcdoc` and `javascript:` URLs on the first render and on every replayed mutation alike, and the CSP (`shared/player-csp.ts`, sent by `vercel.json` and by the backend on its HTML, a test holding the two equal) blocks what the first two miss; the hosted e2e check opens a hostile trace to prove it. That build is `HOSTED_PLAYER` in `app/src/controller/constants.ts`; it never writes the trace to browser storage, and `vercel.json` is also what `vite preview` serves, so its CSP is checked locally before it ships. Several zips or a folder open as a run (`controller/trace-run.ts`), in the hosted player and in `show-trace`, which lists them at `TRACE_API.run` for the app to read: each zip is parsed once, one at a time, and its payload kept for when its row is picked. A zip records no test state, so a row's outcome is read from its rows (`utils/trace-summary.ts`: a failed action or step means failed, actions with none failed mean passed, no actions mean unknown); a jest `expect` that records no row can therefore fail a test whose trace reads as passed. Keeping every payload costs memory in proportion to the run; a reader that reads entries on demand would shrink each payload, which is why the run holds the reader's own payload rather than a cache of its own.
109109

110110
Framework-specific behavior in the backend is contained in two files: `runner.ts` and `framework-filters.ts`. Both branch on a typed `TestRunnerId` from shared, never on a magic string. The `framework-filters` dispatch is a `switch` over `TestRunnerId` (not a table lookup) so CodeQL's `unvalidated-dynamic-method-call` query trusts the call site.
111111

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ pnpm show-trace a/trace.zip b/trace.zip # several zips
271271

272272
Each zip is read once, as the list fills in, and kept for when it is picked. Anything in the folder that is not a `.zip` (screenshots, videos, the artifacts manifest) is skipped; a zip that cannot be opened stays in the list with the reason, and the rest open normally. A zip records no test state, so the outcome is read from the trace itself: **failed** when an action or step in it failed, **passed** when it holds actions and none failed, **unknown** when it holds none. An assertion library that records no row (a jest `expect`, chai) can fail a test whose trace still reads as passed. One zip opens exactly as before, with no list.
273273

274-
**Hosted player: [trace.webdriver.io](https://trace.webdriver.io).** The same player as a web page, for opening a trace without installing anything: pick one or several `.zip` files, or drop them or a folder of them on the page. The archive is parsed in the browser tab and never uploaded, which matters because a trace records everything the run showed or typed. Run controls are absent, as in `show-trace`. A replayed page still loads its own stylesheets and images from the site under test, as it does in `show-trace`; from an `https` page the browser blocks the `http` ones, so a trace of an `http` site can render unstyled there and fully in `show-trace`. To try the hosted build locally:
274+
**Hosted player: [trace.webdriver.io](https://trace.webdriver.io).** The same player as a web page, for opening a trace without installing anything: pick one or several `.zip` files, or drop them or a folder of them on the page. The archive is parsed in the browser tab and never uploaded, which matters because a trace records everything the run showed or typed. Run controls are absent, as in `show-trace`. A trace from someone else is treated as untrusted: its replayed page runs no script, in the hosted player, `show-trace` and the live dashboard alike. A replayed page still loads its own stylesheets and images from the site under test, as it does in `show-trace`; from an `https` page the browser blocks the `http` ones, so a trace of an `http` site can render unstyled there and fully in `show-trace`. To try the hosted build locally:
275275

276276
```sh
277277
pnpm player:hosted # builds it and serves it at http://localhost:4173 under the production headers
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// A trace is untrusted input: whoever recorded it chose the DOM it replays.
2+
const URL_ATTRIBUTES = new Set([
3+
'href',
4+
'src',
5+
'action',
6+
'formaction',
7+
'xlink:href'
8+
])
9+
const SCRIPT_URL = /^\s*javascript:/i
10+
11+
/** An attribute that could run script or navigate the replay frame, whatever the page's policy. */
12+
export function isUnsafeReplayAttribute(name: string, value: unknown): boolean {
13+
const attribute = name.toLowerCase()
14+
if (
15+
/^on[a-z]/.test(attribute) ||
16+
attribute === 'srcdoc' ||
17+
attribute === 'http-equiv'
18+
) {
19+
return true
20+
}
21+
return (
22+
URL_ATTRIBUTES.has(attribute) &&
23+
typeof value === 'string' &&
24+
SCRIPT_URL.test(value)
25+
)
26+
}
27+
28+
export function isUnsafeReplayElement(type: unknown): boolean {
29+
return typeof type === 'string' && type.toLowerCase() === 'script'
30+
}

‎packages/app/src/components/browser/snapshot.ts‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import { commandPageUrl } from './url-at-timestamp.js'
1919
import { mutationForCommand } from './mutation-at-command.js'
2020
import { booleanAttributeOn, isBooleanAttribute } from './boolean-attribute.js'
2121

22-
import { type ComponentChildren, h, render, type VNode } from 'preact'
22+
import { render } from 'preact'
2323
import { customElement, query } from 'lit/decorators.js'
24-
import { transform } from './vnode-transform.js'
24+
import { transform, withBaseUrl } from './vnode-transform.js'
25+
import { isUnsafeReplayAttribute } from './replay-safety.js'
2526
import type { SimplifiedVNode } from '@wdio/devtools-script/types'
2627
// Type-only, like the `script/types` import above: the collector owns the
2728
// characterData wire shape (parent ref + child index), so the replay reads it
@@ -515,22 +516,7 @@ export class DevtoolsBrowser extends Element {
515516
}
516517

517518
async #renderNewDocument(doc: SimplifiedVNode, baseUrl: string) {
518-
const root = transform(doc)
519-
const baseTag = h('base', { href: baseUrl })
520-
const head: VNode<{}> | undefined = (root.props.children as VNode[])
521-
.filter(Boolean)
522-
.find((node) => node!.type === 'head')
523-
if (head) {
524-
head.props.children = [
525-
baseTag,
526-
...(head.props.children as ComponentChildren[])
527-
]
528-
} else {
529-
const head = h('head', {}, baseTag)
530-
const docChildren = (root.props.children as ComponentChildren[]) || []
531-
docChildren.unshift(head)
532-
}
533-
render(root, this.#vdom)
519+
render(withBaseUrl(transform(doc), baseUrl), this.#vdom)
534520
}
535521

536522
#renderVdom() {
@@ -641,6 +627,9 @@ export class DevtoolsBrowser extends Element {
641627
}
642628

643629
const value = mutation.attributeValue
630+
if (isUnsafeReplayAttribute(name, value)) {
631+
return
632+
}
644633
el.setAttribute(name, value)
645634
// Form-field state lives on the PROPERTY, not just the attribute — mirror it
646635
// so a replayed input shows the captured value, including a field cleared
@@ -1041,7 +1030,7 @@ export class DevtoolsBrowser extends Element {
10411030
if (hasMutations) {
10421031
return html`<div class="iframe-wrapper iframe-wrapper--replay">
10431032
<div class="iframe-sizer">
1044-
<iframe class="origin-top-left"></iframe>
1033+
<iframe class="origin-top-left" sandbox="allow-same-origin"></iframe>
10451034
</div>
10461035
</div>`
10471036
}

‎packages/app/src/components/browser/vnode-transform.ts‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
// Converts a serialized VNode (captured by the injected script) into a Preact
22
// VNode the snapshot iframe can render. Pure — no DOM or component state.
33

4-
import { type VNode, h } from 'preact'
4+
import { type ComponentChildren, type VNode, h, toChildArray } from 'preact'
55

66
import { isBooleanAttribute } from './boolean-attribute.js'
7+
import {
8+
isUnsafeReplayAttribute,
9+
isUnsafeReplayElement
10+
} from './replay-safety.js'
711

812
interface SerializedVNode {
913
type?: string
@@ -19,20 +23,18 @@ type TransformInput = SerializedVNode | string | number | null
1923
* Preact reads any `on*` prop as a listener: it stamps its own bookkeeping
2024
* property onto the value, which throws `Cannot create property … on string`
2125
* and aborts the whole render. One such attribute anywhere in the document was
22-
* enough to leave the replay iframe blank with nothing logged — the render
23-
* never reached an `<html>` element, so the caller's own guard returned early.
26+
* enough to leave the replay iframe blank with nothing logged.
2427
*
25-
* Dropping them costs nothing: the replay is a static reconstruction and
26-
* `#renderVdom` strips `<script>` tags for the same reason, so a handler has
27-
* nothing left to fire. A function value (never produced by capture) is left
28-
* alone so a real listener still binds.
28+
* They, and every other attribute that could run script, are dropped: the
29+
* replay is a static reconstruction of an untrusted trace. A function value
30+
* (never produced by capture) is left alone so a real listener still binds.
2931
*/
30-
function withoutInlineHandlers(
32+
function withoutUnsafeAttributes(
3133
props: Record<string, unknown>
3234
): Record<string, unknown> {
3335
const kept: Record<string, unknown> = {}
3436
for (const [key, value] of Object.entries(props)) {
35-
if (!(/^on[A-Za-z]/.test(key) && typeof value === 'string')) {
37+
if (typeof value === 'function' || !isUnsafeReplayAttribute(key, value)) {
3638
kept[key] = value
3739
}
3840
}
@@ -71,8 +73,12 @@ export function transform(node: TransformInput): VNode<{}> {
7173
return node as unknown as VNode<{}>
7274
}
7375

76+
// Every replayed node passes here, an added one included; Preact renders a null child as nothing.
77+
if (isUnsafeReplayElement(node.type)) {
78+
return null as unknown as VNode<{}>
79+
}
7480
const { children, ...rawProps } = node.props ?? {}
75-
const props = withBooleanAttributeState(withoutInlineHandlers(rawProps))
81+
const props = withBooleanAttributeState(withoutUnsafeAttributes(rawProps))
7682
/**
7783
* ToDo(Christian): fix way we collect data on added nodes in script
7884
*/
@@ -92,3 +98,23 @@ export function transform(node: TransformInput): VNode<{}> {
9298
: [childrenRequired]
9399
return h(node.type as string, props, ...c.map(transform)) as VNode<{}>
94100
}
101+
102+
type WithChildren = { children?: ComponentChildren }
103+
104+
/** Puts `<base href>` first in a replayed document's head, adding a head when it has none. */
105+
export function withBaseUrl(root: VNode<{}>, href: string): VNode<{}> {
106+
// Preact keeps one child, or none, as a bare value rather than an array.
107+
const props = root.props as WithChildren
108+
const children = toChildArray(props.children)
109+
const base = h('base', { href })
110+
const head = children.find(
111+
(child): child is VNode<WithChildren> =>
112+
typeof child === 'object' && child.type === 'head'
113+
)
114+
if (head) {
115+
head.props.children = [base, ...toChildArray(head.props.children)]
116+
} else {
117+
props.children = [h('head', {}, base), ...children]
118+
}
119+
return root
120+
}

‎packages/app/tests/hosted-csp.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from 'vitest'
22

3+
import { playerCsp } from '@wdio/devtools-shared'
4+
35
import { hostingHeaders } from '../hosting-headers.js'
46

57
// The replay iframe is same-origin, so it inherits this policy.
@@ -37,4 +39,8 @@ describe('the hosted player CSP', () => {
3739
it('keeps the year-long asset cache off index.html and every other path', () => {
3840
expect(hostingHeaders()['Cache-Control']).toBeUndefined()
3941
})
42+
43+
it('is the policy the local backend sends, so the two cannot drift', () => {
44+
expect(policy).toBe(playerCsp())
45+
})
4046
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
isUnsafeReplayAttribute,
5+
isUnsafeReplayElement
6+
} from '../src/components/browser/replay-safety.js'
7+
8+
describe('isUnsafeReplayAttribute', () => {
9+
it.each([
10+
['onload', 'top.x = 1'],
11+
['onError', 'top.x = 1'],
12+
['ONCLICK', 'top.x = 1'],
13+
['srcdoc', '<script>top.x = 1</script>'],
14+
['http-equiv', 'refresh'],
15+
['href', 'javascript:top.x = 1'],
16+
['href', ' JavaScript:top.x = 1'],
17+
['src', 'javascript:top.x = 1'],
18+
['action', 'javascript:top.x = 1'],
19+
['formaction', 'javascript:top.x = 1'],
20+
['xlink:href', 'javascript:top.x = 1']
21+
])('refuses %s="%s"', (name, value) => {
22+
expect(isUnsafeReplayAttribute(name, value)).toBe(true)
23+
})
24+
25+
it.each([
26+
['href', 'https://example.com/javascript:x'],
27+
['src', 'data:image/png;base64,AAAA'],
28+
['class', 'onload'],
29+
['data-on', 'x'],
30+
['title', 'javascript:x']
31+
])('keeps %s="%s"', (name, value) => {
32+
expect(isUnsafeReplayAttribute(name, value)).toBe(false)
33+
})
34+
})
35+
36+
describe('isUnsafeReplayElement', () => {
37+
it('refuses a script in any case', () => {
38+
expect(isUnsafeReplayElement('script')).toBe(true)
39+
expect(isUnsafeReplayElement('SCRIPT')).toBe(true)
40+
})
41+
42+
it('keeps everything else', () => {
43+
expect(isUnsafeReplayElement('img')).toBe(false)
44+
expect(isUnsafeReplayElement(undefined)).toBe(false)
45+
})
46+
})

‎packages/app/tests/vnode-transform.test.ts‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import { describe, expect, it } from 'vitest'
55
import { render, type VNode } from 'preact'
66

7-
import { transform } from '../src/components/browser/vnode-transform.js'
7+
import {
8+
transform,
9+
withBaseUrl
10+
} from '../src/components/browser/vnode-transform.js'
811
// The producers on the other side of the wire. `transform` only ever sees what
912
// these two emit, so the shapes below are taken from them rather than guessed:
1013
// `parseDocument` serializes the full-DOM anchor (collector.captureCurrentDom),
@@ -427,3 +430,95 @@ describe('transform', () => {
427430
})
428431
})
429432
})
433+
434+
describe('a replayed node from an untrusted trace', () => {
435+
it('renders no script, wherever it sits', () => {
436+
const holder = document.createElement('div')
437+
render(
438+
transform({
439+
type: 'div',
440+
props: {
441+
children: [
442+
{ type: 'script', props: { children: 'top.ran = 1' } },
443+
{ type: 'span', props: { children: 'kept' } }
444+
]
445+
}
446+
}),
447+
holder
448+
)
449+
450+
expect(holder.querySelector('script')).toBeNull()
451+
expect(holder.querySelector('span')?.textContent).toBe('kept')
452+
})
453+
454+
it('drops script URLs and srcdoc but keeps ordinary ones', () => {
455+
const link = transform({
456+
type: 'a',
457+
props: { href: 'javascript:top.ran = 1', title: 'x' }
458+
})
459+
const frame = transform({
460+
type: 'iframe',
461+
props: {
462+
srcdoc: '<script>top.ran = 1</script>',
463+
src: 'https://example.com/'
464+
}
465+
})
466+
467+
expect(propsOf(link).href).toBeUndefined()
468+
expect(propsOf(link).title).toBe('x')
469+
expect(propsOf(frame).srcdoc).toBeUndefined()
470+
expect(propsOf(frame).src).toBe('https://example.com/')
471+
})
472+
})
473+
474+
describe('withBaseUrl', () => {
475+
const page = (head: Serialized[] | Serialized | undefined) =>
476+
transform({
477+
type: 'html',
478+
props: {
479+
children: [
480+
{ type: 'head', props: head === undefined ? {} : { children: head } },
481+
{ type: 'body', props: { children: 'page' } }
482+
]
483+
}
484+
})
485+
486+
const baseOf = (root: VNode<{}>) => {
487+
const holder = document.createElement('div')
488+
render(withBaseUrl(root, 'https://example.com/login'), holder)
489+
return holder.querySelector('head')
490+
}
491+
492+
// Preact keeps zero or one child as a bare value, which once crashed the replay of such a page.
493+
it.each([
494+
['an empty head', undefined],
495+
['a head with one child', { type: 'title', props: { children: 'One' } }],
496+
[
497+
'a head with several children',
498+
[
499+
{ type: 'title', props: { children: 'Many' } },
500+
{ type: 'meta', props: { charset: 'utf-8' } }
501+
]
502+
]
503+
])('puts the base first in %s', (_name, head) => {
504+
const rendered = baseOf(page(head as Serialized[] | Serialized | undefined))
505+
506+
expect(rendered?.firstElementChild?.getAttribute('href')).toBe(
507+
'https://example.com/login'
508+
)
509+
})
510+
511+
it('adds a head to a document without one', () => {
512+
const root = transform({
513+
type: 'html',
514+
props: { children: { type: 'body', props: { children: 'page' } } }
515+
})
516+
const holder = document.createElement('div')
517+
render(withBaseUrl(root, 'https://example.com/'), holder)
518+
519+
expect(holder.querySelector('head > base')?.getAttribute('href')).toBe(
520+
'https://example.com/'
521+
)
522+
expect(holder.querySelector('body')?.textContent).toBe('page')
523+
})
524+
})

‎packages/backend/src/index.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ import {
4141
type BaselineSavedWsPayload,
4242
type BaselineClearedWsPayload,
4343
type RunnerRequestBody,
44-
type SocketMessage
44+
type SocketMessage,
45+
dashboardSocketSource,
46+
playerCsp
4547
} from '@wdio/devtools-shared'
4648

4749
let server: FastifyInstance | undefined
@@ -471,6 +473,16 @@ export async function start(
471473
await server.register(rateLimit, { max: 100, timeWindow: '1 minute' })
472474
await server.register(websocket)
473475
await server.register(staticServer, { root: appPath })
476+
// The dashboard and show-trace replay recorded pages too, so they get the hosted player's policy.
477+
server.addHook('onSend', async (request, reply, payload) => {
478+
if (String(reply.getHeader('content-type') ?? '').startsWith('text/html')) {
479+
reply.header(
480+
'Content-Security-Policy',
481+
playerCsp(dashboardSocketSource(request.headers.host))
482+
)
483+
}
484+
return payload
485+
})
474486

475487
registerTraceRoutes(server, opts)
476488
registerCollectorRoute(server, collectorSource)

0 commit comments

Comments
 (0)