You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.openWpmContentScriptConfig — background/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.
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).
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
ADR-0001, follow-up Documentation on Interfacing with Data Aggregator #4 — docs/developers/adr/0001-retain-legacy-js-instrument.md ("Harden the legacy record channel"), on branch feat/stealth-js-instrument-v2.
OpenWPM channel code (pinned to master = e4389833f04303440dc9397ceedae7104ed3fe5a):
Firefox source (searchfox, pinned to firefox-main rev ad704963dac696aa26a7cb39eded9642c10c0ae0):
ExportHelpers.cpp — xpc::ExportFunction: the exportFunction implementation (a function need not be attached to window; it can be exported to any target scope, including as an argument).
ExportHelpers.cpp — FunctionForwarder: on invocation the forwarder re-enters the original function's realm (JSAutoRealm, #L359-L361) — i.e. the exported callback runs with the content script's privileges — and re-wraps the return value back to the caller's compartment across the Xray boundary (#L397, #L516-L528).
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 DOMCustomEvent, and it delivers its config into the page via a<script>element carrying the serialized spec plusdata-*attributes. Concretely, onmaster(pinned toe4389833f04303440dc9397ceedae7104ed3fe5a):sendMessagesToLoggerfiresdocument.dispatchEvent(new CustomEvent(eventId, { detail: messages }))—javascript-instrument-page-scope.ts#L7-L13. TheeventIdis read off the injected<script>viadocument.currentScript.getAttribute(...)—#L15-L17.sendFactory/sendqueue that drives the dispatch lives injs-instruments.ts#L734-L751, inside the page-worldgetInstrumentJSbody (starts at#L33). The per-call wrapper body that feeds it is#L442-L452(it callsfunc.apply(this, arguments)at#L451— a bare passthrough, all page-world).eventId(javascript-instrument-content-scope.ts#L50) is listened for withdocument.addEventListener(eventId, ...), which relays each message to the background viabrowser.runtime.sendMessage—#L41-L63.getPageScriptAsStringinlines the serialized spec into the injected script's source (const jsInstrumentationSettings = ${JSON.stringify(...)}) and invokes the page-scope IIFE —#L5-L24.insertScriptthen sets the page-reachabledata-event-id/data-testingattributes on that<script>—#L26-L39(attrs at#L35-L36).code:content script that setswindow.openWpmContentScriptConfig—background/javascript-instrument.ts#L121-L147(thecode: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.dispatchEventand theeventIdare page-reachable, the legacy channel is open to:document.dispatchEvent(orEventTarget.prototype.dispatchEvent) to swallow the events, dropping records.eventIdand emits its ownCustomEvent(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 theeventIditself, 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.
Data-out: pass a privileged callback as a closure argument, not via the DOM.
In the isolated content script,
exportFunctiona privileged sink callback and pass it as a closure argument into the page-world IIFE — explicitly not attached towindow. Records then flow through a function handle the page cannot reach, enumerate, or forge, instead of over aCustomEvent. This closes X2-replay (noeventId, no DOM listener to forge into) and X1-dispatchEvent-override (nodispatchEventcall to neuter). Delete theeventIdmachinery (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, sobrowser.runtime.sendMessageworks from inside it, with the return value re-wrapped back across the Xray boundary. See the implementation:xpc::ExportFunctionandNewFunctionForwarderbuild 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 theJSAutoRealm 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).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 standalonedata-event-id/data-testingattributes entirely, leaving no page-reachable config artifact. It pairs naturally with change (1) — both touch the same injected-script surface ingetPageScriptAsString/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, thesetTimeout/clearTimeoutdebounce, 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
docs/developers/adr/0001-retain-legacy-js-instrument.md("Harden the legacy record channel"), on branchfeat/stealth-js-instrument-v2.master=e4389833f04303440dc9397ceedae7104ed3fe5a):eventIdread<script>+data-*attrsfirefox-mainrevad704963dac696aa26a7cb39eded9642c10c0ae0):ExportHelpers.cpp—xpc::ExportFunction: theexportFunctionimplementation (a function need not be attached towindow; it can be exported to any target scope, including as an argument).ExportHelpers.cpp—FunctionForwarder: on invocation the forwarder re-enters the original function's realm (JSAutoRealm,#L359-L361) — i.e. the exported callback runs with the content script's privileges — and re-wraps the return value back to the caller's compartment across the Xray boundary (#L397,#L516-L528).Sandbox.cpp—CreateSandboxObjectand thewantXraysflag: the content-script sandbox compartment and its Xray-into-page view — the "worlds" model the design relies on.exportFunction/cloneIntofor the content-script ↔ page boundary.