Skip to content

Harden the legacy JS instrument's record channel and config delivery (ADR-0001 follow-up #4) #1204

Description

@vringar

Goal: raise the bar on legacy, not flatten the legacy/stealth distinction

The aim of this follow-up is narrow and specific: make a detectable legacy run no longer also trivially disruptable. It is not to remove stealth's advantages, and not to make legacy a co-equal peer of stealth. Stealth remains the default and the stronger instrument. This work hardens one specific weakness of the legacy path — its page-reachable record channel and config-delivery artifacts — so that opting into legacy (for its two narrow capability gaps; see ADR-0001) no longer also forfeits tamper-resilience of the channel.

This is follow-up #4 of ADR-0001 ("Harden the legacy record channel"). It is a separate change from the stealth PR (#1154) and is not blocking it.

Background: where legacy runs, and why it needs a DOM bounce

The legacy instrument's wrapper bodies and its exfiltration call execute in the page world (no browser.* access), so it must bounce captured records back to the isolated content world over a page-reachable DOM CustomEvent, and it delivers its config into the page via a <script> element carrying the serialized spec plus data-* attributes. Concretely, on master (pinned to e4389833f04303440dc9397ceedae7104ed3fe5a):

  • Exfil dispatch (page world). sendMessagesToLogger fires document.dispatchEvent(new CustomEvent(eventId, { detail: messages }))javascript-instrument-page-scope.ts#L7-L13. The eventId is read off the injected <script> via document.currentScript.getAttribute(...)#L15-L17.
  • Exfil queue (page world). The debounced sendFactory/send queue that drives the dispatch lives in js-instruments.ts#L734-L751, inside the page-world getInstrumentJS body (starts at #L33). The per-call wrapper body that feeds it is #L442-L452 (it calls func.apply(this, arguments) at #L451 — a bare passthrough, all page-world).
  • Receive + relay (content/isolated world). A random eventId (javascript-instrument-content-scope.ts#L50) is listened for with document.addEventListener(eventId, ...), which relays each message to the background via browser.runtime.sendMessage#L41-L63.
  • Config delivery. getPageScriptAsString inlines the serialized spec into the injected script's source (const jsInstrumentationSettings = ${JSON.stringify(...)}) and invokes the page-scope IIFE — #L5-L24. insertScript then sets the page-reachable data-event-id / data-testing attributes on that <script>#L26-L39 (attrs at #L35-L36).
  • Isolated-world config global. The background registers a code: content script that sets window.openWpmContentScriptConfigbackground/javascript-instrument.ts#L121-L147 (the code: injection at #L130-L139). This lands on the isolated-world window, not the page, so it is not page-reachable; noted for completeness.

Why this is a disruption surface

Because both document.dispatchEvent and the eventId are page-reachable, the legacy channel is open to:

  • X1 (suppression): the page overrides document.dispatchEvent (or EventTarget.prototype.dispatchEvent) to swallow the events, dropping records.
  • X2 (forgery): the page reads the eventId and emits its own CustomEvent(eventId, { detail: [forged] }), injecting fabricated rows into the dataset.

(These are exactly the X1/X2 vectors the stealth requirements document and the reliability paper — Krumnow, Jonker & Karsch, arXiv:2205.08890, 2022 — describe.) The data-* config attributes are a secondary, transient fingerprint surface; the dominant leak is the eventId itself, which is intrinsic to the current exfil channel.

Proposed Tier A (the actionable follow-up)

A small, localized change to the two content scripts that makes the channel page-unreachable.

  1. Data-out: pass a privileged callback as a closure argument, not via the DOM.
    In the isolated content script, exportFunction a privileged sink callback and pass it as a closure argument into the page-world IIFE — explicitly not attached to window. Records then flow through a function handle the page cannot reach, enumerate, or forge, instead of over a CustomEvent. This closes X2-replay (no eventId, no DOM listener to forge into) and X1-dispatchEvent-override (no dispatchEvent call to neuter). Delete the eventId machinery (Math.random() id, document.addEventListener, data-event-id, document.dispatchEvent).

    This is sound in Firefox: an exported function need not live on window — it can be handed in as a function argument — and when invoked it runs the forwarder in the original (privileged) realm, so browser.runtime.sendMessage works from inside it, with the return value re-wrapped back across the Xray boundary. See the implementation: xpc::ExportFunction and NewFunctionForwarder build a forwarder that lives in the target (page) compartment but, on call, re-enters the original function's realm before invoking it — ExportHelpers.cpp#L444 (ExportFunction), #L332 (FunctionForwarder, with the JSAutoRealm ar(cx, unwrappedFun) realm re-entry around #L359-L361), and #L516-L528 (forwarder created in the target compartment, return value re-wrapped at #L397/#L543-L544).

  2. Config-in: fold the spec + event id into the injected IIFE's arguments (closure-inline).
    Replace the document.currentScript.getAttribute(...) reads with closure parameters baked directly into the injected IIFE argument list, e.g. (function (spec, sink) { ... })(<spec>, <exportedSink>). This drops the standalone data-event-id/data-testing attributes entirely, leaving no page-reachable config artifact. It pairs naturally with change (1) — both touch the same injected-script surface in getPageScriptAsString / insertScript / the page-scope signature.

Honest caveat (must read)

This hardens the CHANNEL, not the PRODUCER. While legacy's wrapper bodies still run in the page world, the page co-inhabits the producer path: it can neuter the feed/debounce (e.g. clobber Array.prototype.push, the setTimeout/clearTimeout debounce, or redefine the wrapped property before capture) or push attacker-influenced real records through the genuine instrumented call path. So Tier A does not make legacy fully undisruptable — it closes the channel-replay and channel-suppression holes (X1-dispatchEvent-override, X2-replay), but the producer-path variants of X1/X2 remain open.

Full tamper-resilience requires moving the record producers off-page into the isolated world — which is precisely the stealth architecture. This validates (does not contradict) ADR-0001's narrow claim: only the channel is hardenable while legacy stays page-world; the broader "detectable-but-undisruptable legacy" property needs more than a channel swap. Expect the X1/X2 tests, re-run against a Tier-A legacy build, to show improvement on the channel-replay/override variants, not a full pass on producer-path variants.

Out of scope (Tier B)

Moving legacy's wrapper bodies into the isolated world is out of scope for this follow-up. That is a re-architecture that collides with the exact capabilities legacy is retained for: Firefox's Xray forbids accessor-defines on plain [Object]/[Array] Xray instances (ADR-0001 Decision 1) and forces per-interface rather than per-instance attribution (Decision 2). Tier B is effectively re-implementing legacy as stealth and would defeat the reason legacy exists. Note it and stop.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNot a bug or a feature requestextensionRelates to the WebExtension written in TS and JS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions