From 3ebc9153cfef7f81e203c729f8c4ef8dceaf7d72 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Tue, 28 Jul 2026 00:43:11 -0600 Subject: [PATCH] fix(juggler): honour forceScopeAccess without relocating the main world (#628) `forceScopeAccess` was declared in settings/properties.json and settings/camoucfg.jvv, validated on the way in, and then read by nothing -- grep found no consumer anywhere in additions/juggler/. So `element.shadowRootUnl`, which patches/shadow-root-bypass.patch adds to Element.webidl gated on Func="Document::IsCallerChromeOrAddon", was `undefined` from page.evaluate() no matter what the flag was set to. Reproduced on 152.0.4-beta.28. The gate tests the caller, not the world the property is defined in. So export a getter whose body stays in FrameTree.js's system-principal scope and install it on the page's own Element.prototype. The default execution context is left alone: page.evaluate() still runs against the real page window. Deliberately NOT taking PR #685's shape. It unlocks the same binding by swapping the default main world for a Cu.Sandbox over the page window, and with Xray vision that hides page expandos -- its own tests assert `page.evaluate('window.pageSecret') is None` and `element.pageMarker is None`. Enabling a shadow-DOM flag should not silently stop page.evaluate() from seeing page state; that is a much worse failure than the two caveats below, and it is undocumented in the PR. The trade-offs of keeping evaluation in the main world, both documented at the call site: the property is visible to the page while the flag is on (so it can be fingerprinted -- hence opt-in and off by default), and a page that defines its own `shadowRootUnl` on an element shadows the accessor. A page can only do either if it already knows the property exists. Credit to @Cloudymap1e (#685) for the Cu.exportFunction technique this reuses. Adds tests/patches/force-scope-access.py, which pins both halves: the binding works with the flag on, is absent with it off, and main-world evaluation sees page globals, page expandos and element handles identically in both modes. Co-Authored-By: Claude Opus 5 (1M context) --- additions/juggler/content/FrameTree.js | 55 ++++++++- tests/patches/force-scope-access.py | 164 +++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 tests/patches/force-scope-access.py diff --git a/additions/juggler/content/FrameTree.js b/additions/juggler/content/FrameTree.js index e46bed6..42c929a 100644 --- a/additions/juggler/content/FrameTree.js +++ b/additions/juggler/content/FrameTree.js @@ -556,10 +556,13 @@ class Frame { this._runtime.destroyExecutionContext(context); this._worldNameToContext.clear(); - this._worldNameToContext.set('', this._runtime.createExecutionContext(this.domWindow(), this.domWindow(), { + const domWindow = this.domWindow(); + this._worldNameToContext.set('', this._runtime.createExecutionContext(domWindow, domWindow, { frameId: this._frameId, name: '', })); + if (ChromeUtils.camouGetBool('forceScopeAccess', false)) + installClosedShadowAccessor(domWindow); for (const [name, world] of this._frameTree._isolatedWorlds) { if (name) this._createIsolatedContext(name); @@ -690,4 +693,54 @@ function channelId(channel) { return helper.generateId(); } +// Camoufox: make Element.shadowRootUnl reachable from page.evaluate() when the +// `forceScopeAccess` config key is set. +// +// patches/shadow-root-bypass.patch adds `shadowRootUnl` to Element.webidl gated +// on Func="Document::IsCallerChromeOrAddon". page.evaluate() runs as the page +// principal, so that gate never passed and the property read as `undefined` -- +// the flag was declared in settings/properties.json and settings/camoucfg.jvv +// but nothing ever consulted it (#628). +// +// The gate tests the *caller*, not the world the property lives in. So export a +// getter whose body stays in this module's system-principal scope and install it +// on the page's own Element.prototype. The main world is left exactly as it was: +// page.evaluate() still runs against the real page window and still sees page +// globals, expandos, and handles. +// +// Two caveats, both inherent to keeping evaluation in the main world: +// * The property is defined on the page's prototype, so the page can see it +// too while the flag is on. Anything that probes for it can therefore +// fingerprint the flag -- which is why this stays opt-in and off by default. +// * A page that defines its own `shadowRootUnl` on an element shadows the +// prototype accessor and can return whatever it likes. A page can only do +// that if it already knows the property exists, i.e. already detected us. +// +// The alternative (PR #685) hides the accessor in a Cu.Sandbox, which defeats +// both caveats but relocates the default execution context: with Xray vision on +// a sandboxPrototype window, page expandos become invisible, so +// page.evaluate('window.pageSecret') starts returning null for anyone who turns +// the flag on. Silently breaking evaluate is the worse trade. +// Credit to @Cloudymap1e (#685) for the Cu.exportFunction technique. +function installClosedShadowAccessor(domWindow) { + try { + const getter = Cu.exportFunction(function() { + // `this` arrives Xray-wrapped, so this read is attributed to the + // system-principal caller and Document::IsCallerChromeOrAddon passes. + return this.shadowRootUnl; + }, domWindow); + // Waive Xrays so the definition lands in the page's compartment rather than + // in the chrome-only Xray expando holder, where page script could not + // reach it. + const pageGlobal = Cu.waiveXrays(domWindow); + Object.defineProperty(pageGlobal.Element.prototype, 'shadowRootUnl', { + configurable: true, + enumerable: false, + get: getter, + }); + } catch (e) { + dump(`juggler: failed to install shadowRootUnl accessor: ${e}\n`); + } +} + diff --git a/tests/patches/force-scope-access.py b/tests/patches/force-scope-access.py new file mode 100644 index 0000000..249e6c2 --- /dev/null +++ b/tests/patches/force-scope-access.py @@ -0,0 +1,164 @@ +""" +Verify `forceScopeAccess` reaches closed shadow roots without relocating the +main world (daijro/camoufox#628). + +patches/shadow-root-bypass.patch adds `Element.shadowRootUnl` to the WebIDL, +gated on Func="Document::IsCallerChromeOrAddon". page.evaluate() runs as the +page principal, so that gate never passed and the property read as `undefined`. +Meanwhile `forceScopeAccess` was declared in settings/properties.json and +settings/camoucfg.jvv but nothing in additions/juggler/ ever consulted it, so +the flag was accepted, validated, and silently ignored. + +FrameTree.js now installs a getter whose body stays in the juggler module's +system-principal scope, so the WebIDL gate is satisfied by the *caller* while +the default execution context remains the real page window. + +That last part is the point of this test. The obvious alternative -- evaluating +in a Cu.Sandbox over the page window -- also unlocks the binding, but Xray +vision then hides page expandos, so `page.evaluate('window.pageSecret')` +silently starts returning None for anyone who enables the flag. These cases pin +both halves: the binding works AND main-world semantics are untouched. + +Run against a specific build: + CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python tests/patches/force-scope-access.py +(without the env var it uses the camoufox-managed browser download.) + +What PASS means: + * with forceScopeAccess=True, page.evaluate() can read a closed shadow root + through `element.shadowRootUnl` and query inside it; + * with the flag off (the default), `shadowRootUnl` is undefined -- the + accessor is not installed and the default build gains no new surface; + * in BOTH modes page.evaluate() still sees page globals and page expandos, + element handles still resolve, and ChromeUtils is not exposed to the page. +""" + +import asyncio +import os +import sys +from typing import Any, Dict + +from camoufox.async_api import AsyncCamoufox + +EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH") + +PAGE = """ +
+
content
+ +""" + + +def _launch_kwargs(force_scope_access: bool) -> Dict[str, Any]: + kwargs: Dict[str, Any] = dict(headless=True, os="linux") + if force_scope_access: + # Not a documented Camoufox() kwarg; pass it straight through as config. + kwargs["config"] = {"forceScopeAccess": True} + if EXECUTABLE_PATH: + kwargs["executable_path"] = EXECUTABLE_PATH + return kwargs + + +def _check(results: Dict[str, Any], label: str, got: Any, expected: Any) -> None: + ok = got == expected + results[label] = ok + verdict = "PASS" if ok else "FAIL" + suffix = "" if ok else f" (expected {expected!r})" + print(f" {verdict} {label:38} -> {got!r}{suffix}") + + +async def _run(force_scope_access: bool) -> bool: + results: Dict[str, Any] = {} + print(f"\n=== forceScopeAccess={force_scope_access} ===") + async with AsyncCamoufox(**_launch_kwargs(force_scope_access)) as browser: + page = await browser.new_page() + await page.set_content(PAGE) + + # --- the feature itself --- + shadow_type = await page.evaluate( + "typeof document.querySelector('#host').shadowRootUnl" + ) + _check( + results, + "typeof element.shadowRootUnl", + shadow_type, + "object" if force_scope_access else "undefined", + ) + + if force_scope_access: + text = await page.evaluate( + "document.querySelector('#host').shadowRootUnl" + ".querySelector('#secret').textContent" + ) + _check(results, "closed shadow root is queryable", text, "inside") + # The plain, spec-compliant accessor must still refuse. + _check( + results, + "spec .shadowRoot still null for closed", + await page.evaluate("document.querySelector('#host').shadowRoot"), + None, + ) + + # --- main-world semantics must be identical in both modes --- + _check(results, "page global visible to evaluate", await page.evaluate("window.pageSecret"), 41) + _check( + results, + "page expando visible to evaluate", + await page.evaluate("document.querySelector('#target').pageMarker"), + "page-owned", + ) + + handle = await page.evaluate_handle("() => ({value: 42})") + _check(results, "evaluate_handle round-trips", await handle.json_value(), {"value": 42}) + await handle.dispose() + + element = await page.query_selector("#target") + _check( + results, + "element handle resolves", + await element.get_attribute("id") if element else None, + "target", + ) + + # --- the page must not gain chrome privileges either way --- + _check( + results, + "page cannot see ChromeUtils", + await page.get_attribute("html", "data-chrome-utils-type"), + "undefined", + ) + _check( + results, + "evaluate cannot see ChromeUtils", + await page.evaluate("typeof ChromeUtils"), + "undefined", + ) + + return all(results.values()) + + +async def main() -> int: + passed = True + for force_scope_access in (False, True): + if not await _run(force_scope_access): + passed = False + + print() + if passed: + print("PASS: forceScopeAccess unlocks closed shadow roots and main-world " + "evaluation is unchanged") + else: + print("FAIL: forceScopeAccess is ignored, or it altered main-world evaluation") + print() + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))