Skip to content

fix(extension): strip moz-extension frames from legacy instrument error stacks - #1207

Draft
vringar wants to merge 1 commit into
masterfrom
harden/legacy-error-stacks
Draft

fix(extension): strip moz-extension frames from legacy instrument error stacks#1207
vringar wants to merge 1 commit into
masterfrom
harden/legacy-error-stacks

Conversation

@vringar

@vringar vringar commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Problem

The legacy JS instrument runs in the page world (its instrument body is
stringified into a page <script> via ${getInstrumentJS}). Until now it
re-threw exceptions raised by instrumented APIs bare, with no sanitization
(Extension/src/lib/js-instruments.ts, instrumentFunction's
return func.apply(this, arguments) and the getter/setter call sites).

Because the rethrow propagates the original error back out through the
instrument's own wrapper frames, any page that does
try { instrumentedApi() } catch (e) { e.stack } could read the extension's
frames off e.stackmoz-extension://<uuid>/... — and trivially detect the
instrument. This is a classic anti-measurement detection vector.

Fix

Back-port the stealth instrument's stack-cleaning predicate to legacy, adapted
for the page world:

  • Add two small inline page-world helpers inside getInstrumentJS (no ES6
    imports / no webpack runtime refs, so they survive the stringification into
    the page <script>):
    • cleanErrorStack(stack) — drops every stack line containing the literal
      moz-extension:// scheme (same predicate as
      Extension/src/stealth/error.ts).
    • rethrowWithCleanStack(err) — overwrites the caught error's own .stack
      with the cleaned value and re-throws the same error object.
  • Wrap the three page-observable call sites (instrumentFunction's
    func.apply, and the instrumented getter's / setter's original*.call) in
    try/catch that re-throws via rethrowWithCleanStack.

Unlike the stealth instrument (which runs in the isolated content world behind
an Xray wrapper and must reconstruct the error via wrappedJSObject), legacy
already runs in the page world. The caught error is therefore a page-world
object built by the page's own constructors — so we simply overwrite its own
.stack (which shadows the prototype accessor) and re-throw it, preserving the
error's original type and identity. Thrown primitives (no .stack) are
re-thrown unchanged.

Test

test/test_js_instrument_error_stack.py instruments window.atob, makes it
throw from page code (atob("…invalid base64…")InvalidCharacterError),
catches the error in the page, records its .stack, and asserts the
page-observable stack contains no moz-extension:// frame. Passes on
Firefox 152.

Closes crosslink #53 (review item R16).

@vringar

vringar commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Local verification status (draft):

  • npm ci && npm run build — green xpi; the new cleanErrorStack / rethrowWithCleanStack helpers are confirmed present in the compiled content.js (so they survive stringification into the page <script>).
  • npm run lint — green.
  • Python pre-commit (isort/black/mypy) — green.

The browser regression test (test/test_js_instrument_error_stack.py) could not be run to completion locally: in this environment the OpenWPM browser-spawn path fails for all browser tests, including unmodified pre-existing ones (e.g. TestJSInstrumentByPython::test_instrument_object fails identically with a spawn failure before profile creation). A direct headless Firefox 152 + geckodriver launch works, so the binaries are fine — the failure is in OpenWPM's fork-based multiprocess spawn under Python 3.14.6 (os.fork() in a multi-threaded process), unrelated to this change. Leaving as draft so CI validates the browser test.

@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 12.50000% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.93%. Comparing base (975b09c) to head (71accf0).

Files with missing lines Patch % Lines
test/test_js_instrument_error_stack.py 12.50% 28 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1207      +/-   ##
==========================================
- Coverage   62.31%   61.93%   -0.38%     
==========================================
  Files          40       41       +1     
  Lines        3930     3962      +32     
==========================================
+ Hits         2449     2454       +5     
- Misses       1481     1508      +27     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… extension frames

The legacy JS instrument is injected as an inline page-world <script>, so
Firefox attributes all of its frames -- including the wrapper closures -- to the
page document URL, never to moz-extension://. A page that catches an error
thrown by an instrumented API therefore sees only page-URL frames on
error.stack; no moz-extension:// frame reaches a page-observable stack on this
path.

This test asserts that end-to-end invariant. It holds by construction on the
current injection model, so it is not a reproduction of a fixed leak. It earns
its keep as a regression guard: if the injection model ever changes to an
extension-world script (or a //# sourceURL=moz-extension://... directive) that
lets extension frames reach page code, the assertion fails and flags that the
leak must be addressed.
@vringar
vringar force-pushed the harden/legacy-error-stacks branch from 0ed2251 to 71accf0 Compare September 6, 2026 20:24
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