mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-11 08:00:51 +00:00
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:
co-authored by
Claude Opus 5
parent
35c133c969
commit
3ebc9153cf
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user