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) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-07-30 14:32:42 -06:00
co-authored by Claude Opus 5
parent 35c133c969
commit 3ebc9153cf
2 changed files with 218 additions and 1 deletions
+54 -1
View File
@@ -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`);
}
}
+164
View File
@@ -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 = """
<div id="host"></div>
<main id="target">content</main>
<script>
const host = document.querySelector('#host');
const root = host.attachShadow({mode: 'closed'});
root.innerHTML = '<span id="secret">inside</span>';
// Page-owned state that main-world evaluation must keep seeing.
window.pageSecret = 41;
document.querySelector('#target').pageMarker = 'page-owned';
document.documentElement.dataset.chromeUtilsType = typeof ChromeUtils;
</script>
"""
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()))