Skip to content

Run no script from a replayed trace, in any player mode - #527

Merged
vishnuv688 merged 2 commits into
mainfrom
fix/replay-script-isolation
Nov 2, 2026
Merged

vishnuv688 merged 2 commits into
mainfrom
fix/replay-script-isolation

Conversation

@vishnuv688

@vishnuv688 vishnuv688 commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

What & why

Part of #494. Stacked on #523.

A trace is untrusted input: whoever recorded it chose the DOM the player replays, and the hosted player invites opening traces from other people. The hosted player's CSP already stopped a replayed page running script, but show-trace and the live dashboard sent no CSP, and the replay applied later DOM updates without filtering them. This makes a replayed trace run no script in every mode, with three independent layers.

Type of change

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Polish (an improvement to an existing feature)
  • Breaking change (existing behavior changes for users)
  • Documentation
  • Internal (build, CI, dependencies, tooling)

Packages touched

  • shared (types and contracts)
  • core (framework-agnostic capture/reporting)
  • elements (published element/snapshot API — @wdio/elements)
  • service (WebdriverIO adapter)
  • nightwatch-devtools (Nightwatch adapter)
  • selenium-devtools (Selenium adapter)
  • selenium-devtools-py (Selenium Python adapter)
  • backend (server)
  • app (UI)
  • script (page-injected runtime)
  • trace (Trace mode)

Plus conformance (the hosted e2e check).

Capability matrix

  • packages/conformance/src/features.ts updated, and tables regenerated with pnpm docs:capabilities, if this adds or changes user-facing behaviour

Not needed: no per-adapter capability changes.

Notes for reviewers

Layers

Layer Change Where it applies
Sandboxed replay frame <iframe sandbox="allow-same-origin">: the app still writes the replayed DOM, nothing inside runs, and nested frames inherit the sandbox every mode
Filtering components/browser/replay-safety.ts drops <script>, on* attributes, srcdoc, http-equiv and javascript: URLs, on the first render and on every replayed mutation (previously only the first render dropped scripts and handlers) every mode
One CSP shared/player-csp.ts is the policy. The backend sends it on its HTML responses, with the live dashboard's WebSocket source taken from the Host header only when it is a plain host; vercel.json sends it for the hosted player, and a test holds the two equal show-trace and live gain what hosted had

Replay crash found by the hostile trace: starting a replayed page puts a <base> first in its head, and the code spread head.props.children as an array. Preact stores one child as the child itself and none as undefined, so a page whose head had zero or one element crashed the replay (props.children is not iterable), and a document with no head lost its <base>. withBaseUrl in vnode-transform.ts reads both through toChildArray, with tests for an empty head, one child, several, and no head.

frame-ancestors 'none' now also applies to the local dashboard, so a website cannot frame localhost and lure clicks on Rerun. Nothing in the repo embeds the dashboard in a frame.

Testing

  • pnpm install --frozen-lockfile, pnpm build, pnpm test (3526 passed) and pnpm lint are green; the built app carries the sandbox attribute.
  • New tests: the filter (refused and kept cases), the transform dropping a nested <script>, javascript: and srcdoc, the CSP builder and Host handling, the backend header (a malformed Host reflects nothing, API responses carry no CSP), and vercel.json equal to the shared policy. Removing the script filter or the backend header makes them fail.
  • The hosted e2e check (Check the hosted trace player end to end on every PR #518) now opens a hostile trace that tries three paths: a handler in the first render, a handler added by a later update and then triggered, and an added <script>. It fails unless the page was replayed, the frame is sandboxed, no <script> reached the frame, and nothing ran. This PR's hosted-player job is its first run.

Needs checking by eye (the sandbox changes how the replay frame runs, and the backend sends a CSP for the first time)

  1. pnpm show-trace <zip>: the replay renders styled; scrolling, the element highlight and the A11y overlay work.
  2. pnpm player:hosted: the same.
  3. pnpm demo:wdio:mocha (live): the dashboard connects and updates, with no CSP error in the console.

Not in this PR: the remaining ways a trace touches the outside world are tracked: replay loading the recorded site's own stylesheets and images (#499), and redacting what a trace records (#503).

Screenshots / recordings

Not applicable: no visual change intended.

🤖 Generated with Claude Code

A trace is untrusted input: whoever recorded it chose the DOM the player
replays. The hosted player's CSP stopped that DOM running script, but the
dashboard and show-trace sent no CSP, and the replay applied mutations
unfiltered, including added <script> nodes and on* attributes.

Three layers now, any one of which stops it:
- the replay iframe is sandboxed to allow-same-origin, so nothing in it
  runs while the app can still write the replayed DOM;
- replay-safety.ts drops <script>, on* attributes, srcdoc, http-equiv and
  javascript: URLs on the first render and on every mutation alike;
- the CSP lives once in shared/player-csp.ts, the backend sends it on its
  HTML with the live dashboard's socket, and a test holds vercel.json
  equal to it.

The hosted e2e check opens a hostile trace that tries each path and fails
if any script runs or the frame is not sandboxed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starting a replayed page puts a <base> tag first in its head, and the code
spread head.props.children as an array. Preact stores one child as the child
itself and none as undefined, so a page whose head had zero or one element
crashed the replay with "props.children is not iterable", and a document
with no head lost its <base>, its relative stylesheets then resolving
against the player. The hosted check's hostile trace, whose head is empty,
found it. withBaseUrl reads both through toChildArray.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vishnuv688
vishnuv688 changed the base branch from feat/501-open-whole-run to main November 2, 2026 10:24
@vishnuv688
vishnuv688 merged commit b8a8617 into main Nov 2, 2026
10 checks passed
@vishnuv688
vishnuv688 deleted the fix/replay-script-isolation branch November 2, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant