Skip to content

feat(replay): origin-isolated embedded replayer over postMessage (SEC-8885) - #37

Draft
mayberryzane wants to merge 3 commits into
mainfrom
sec-8885-origin-isolation-embedded-host
Draft

feat(replay): origin-isolated embedded replayer over postMessage (SEC-8885)#37
mayberryzane wants to merge 3 commits into
mainfrom
sec-8885-origin-isolation-embedded-host

Conversation

@mayberryzane

Copy link
Copy Markdown
Contributor

Why

Foundation for origin isolation — option #2 in the Observability XSS cluster analysis. SEC-8885 is a zero-click stored XSS: the canvas deserializer's new window[rr_type](...) runs in the host page's realm (the Replayer runs in the parent and only reaches into the child iframe), so a poisoned Promise(Function("code")) executes as first-party JS on app.launchdarkly.com — full account takeover. The replayer <meta> CSP (#36) doesn't stop it because that CSP only governs the rebuilt-DOM child, not the parent realm where the deserializer runs.

The durable fix is to run the Replayer inside a sandboxed, cookieless, cross-origin iframe and drive it from the parent — so even if attacker JS executes, it runs somewhere with no app cookies, no same-origin API access, and no reach into the parent window. This PR adds the rrweb-side capability that makes that possible; the current published API can't, because it assumes parent-realm execution.

What

Purely additive. Existing in-parent Replayer usage is unchanged.

  • protocol.ts — typed, versioned, namespaced postMessage contract. Data-only (no functions/DOM ever cross the boundary; nothing is eval'd or constructed by name from a message). Both sides authenticate their peer: the host by event.origin (against a declared parent origin), the client by event.source identity (a sandboxed opaque-origin frame reports event.origin === "null", so source identity is the reliable check). pickSerializableConfig allowlists the data-only playerConfig subset.
  • host.tsEmbeddedReplayerHost / startEmbeddedReplayerHost run inside the iframe: construct the Replayer in that realm, forward ReplayerEvents back, apply playback/config commands, answer getter RPCs, and push current-time telemetry while playing.
  • client.tsEmbeddedReplayerClient mirrors the control / event / getter surface the viewer needs, over the bridge.
  • host-document.tsbuildHostDocument emits the sandbox HTML shell with a <meta> CSP whose script-src omits unsafe-eval — this kills the new Function(...) canvas path even inside the sandbox (defense in depth on top of the origin isolation itself).

Isolation without a new domain

An iframe with sandbox="allow-scripts" without allow-same-origin is forced into an opaque origin regardless of the URL it loads from — cookieless and cross-site to the app. That defeats the SEC-8885 PoC as a code-only change (no DNS/TLS/hosting). A dedicated cross-site domain remains a stronger follow-up (preserves same-origin among the replay's own nested frames, enables a real HTTP-header CSP), and the host origin is a config value so promoting later is a swap, not a rewrite.

Validation

  • tsc -noEmit clean, eslint clean.
  • 10 unit tests (test/replay/embedded.test.ts): protocol guards, config sanitization (drops functions/DOM/unknown keys), client command/event/RPC round-trip, and untrusted-source rejection.
  • Not covered here (follow-ups): host runtime e2e with a real Replayer, and nested-iframe replay fidelity under isolation.

Downstream

gonfalon consumes this as @highlight-run/rrweb. After merge: publish 2.0.0-lambda.8 → bump the dep in gonfalon → gonfalon integration PR wires EmbeddedReplayerClient into the Sessions viewer behind a flag.

🤖 Generated with Claude Code

mayberryzane and others added 3 commits July 24, 2026 13:56
…-8885)

Add an embedded-replay bridge so the Replayer can run inside a sandboxed,
cookieless, cross-origin iframe and be driven from the parent page, instead of
running in the embedding app's privileged origin.

- protocol.ts: typed, versioned, namespaced message contract. Data-only
  (no functions/DOM cross the boundary) and both sides authenticate their peer
  (host by event.origin, client by event.source identity). pickSerializableConfig
  allowlists the data-only playerConfig subset.
- host.ts: EmbeddedReplayerHost runs inside the iframe, constructs the Replayer
  in that realm, forwards ReplayerEvents back, applies control commands, answers
  getter RPCs, and pushes current-time telemetry while playing.
- client.ts: EmbeddedReplayerClient mirrors the Replayer control/event/getter
  surface the viewer needs, over the bridge.
- host-document.ts: buildHostDocument emits the sandbox HTML shell with a <meta>
  CSP whose script-src omits unsafe-eval (kills the canvas Function() path even
  inside the sandbox).

Purely additive; existing in-parent Replayer usage is unchanged. Unit tests
cover the protocol guards, config sanitization, and the client bridge behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Drop the request/response RPC layer. The viewer's stable getters
  (getMetaData, getActivityIntervals) now ride the `initialized` message
  (re-sent on replaceEvents) and current time streams via `time`, so the
  client's getters are synchronous cached reads. Removes pending-map/request-id
  bookkeeping on the client and reply() on the host.
- Replace the rAF + manual-throttle time pump with a plain setInterval.
- Reuse the package's playerMetaData / SessionInterval types instead of the
  parallel PlayerMetaDataLike; drop the SerializablePayload alias.
- Require a configured parentOrigin (option or query param) instead of the
  trust-on-first-message fallback; host ignores messages when none is set.

Command surface (resume/addEvent/enable/disableInteract) intentionally kept for
now; it'll be trimmed to actual callers when the gonfalon integration lands.

Typecheck + lint clean; 10 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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