mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
release: 0.1.8 - fix #20 cross-origin iframe regression (pref-only)
Pin fission.webContentIsolationStrategy=0 (IsolateNothing) in baseline
prefs. FF150 ships strategy=1 (IsolateEverything) by default, which
site-isolates every cross-origin iframe into a separate webIsolated
content process even when fission.autostart=False - different from
FF146 Playwright behavior. The parent's Juggler FrameTree then sees
the iframe placeholder with no docShell, no URL and a stale execution
context, so content_frame() returns None, frame.evaluate() throws
cross-origin permission errors, and frame_locator(...).click() times
out. Reproduced with a local cross-origin HTTP harness (two 127.0.0.1
ports = two SOP origins).
The fix is a single pref because that's the level the original Juggler
code paths assume - vanilla Playwright Firefox 146 ran with the looser
default. Disabling site-isolation costs nothing for a single-user
puppet browser; process-per-browser/profile isolation is unaffected.
Caller can A/B per session via
extra_prefs={"fission.webContentIsolationStrategy": 1}.
Tests: tests/test_cross_origin_iframe.py adds 4 unit sentinels (pref
in baseline, survives translate_profile_to_prefs, extra_prefs override
works) and 5 e2e sentinels that spin up two local HTTP servers on
random free ports and verify the four protocol operations that
regressed (page.frames URL tracking, content_frame(), frame.evaluate(),
frame_locator(...).locator(...)) plus dispatch_event('click')
end-to-end, for plain, sandboxed and titled iframes. A future FF
upgrade or A/B flipping the pref will fail the suite before shipping.
Verified clean: pytest -m unit (342 passed), fppro_full.py (ALL
CRITICAL FLAGS CLEAN), fppro_consistency.py (visitor_id stable).
BINARY_VERSION stays firefox-7 - Python-only release.
Issue: https://github.com/feder-cr/invisible_playwright/issues/20
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb3755cdd5
commit
64eef4daff
4 changed files with 335 additions and 13 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.8] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- [#20](https://github.com/feder-cr/invisible_playwright/issues/20): cross-origin iframes were unreachable from Playwright. `element_handle.content_frame()` returned `None`, `frame.evaluate()` threw cross-origin SOP errors, and `frame_locator(...).click()` timed out even with `force=True`. Root cause: FF150 defaults `fission.webContentIsolationStrategy=1` (`IsolateEverything`), which site-isolates every cross-origin iframe into a separate `webIsolated` content process even when `fission.autostart=False`. The parent's Juggler FrameTree then has a Frame placeholder with no docShell and no URL — every protocol op that needs to enter the iframe fails. Fix: pin `fission.webContentIsolationStrategy=0` (`IsolateNothing`) in the baseline prefs. The setting can be flipped back per session via `extra_prefs={"fission.webContentIsolationStrategy": 1}`.
|
||||
|
||||
### Added
|
||||
- `tests/test_cross_origin_iframe.py`: 4 unit + 5 e2e regression sentinels for cross-origin iframe interaction. The e2e layer runs entirely offline against two local HTTP servers on `127.0.0.1` (two ports = two SOP origins) and covers `page.frames` URL tracking, `content_frame()`, `frame.evaluate()`, `frame_locator(...).locator(...)`, and end-to-end `dispatch_event("click")` for plain, sandboxed and titled iframes. A future FF upgrade or fingerprint A/B that flips the pref back to `1` will fail the suite before shipping.
|
||||
|
||||
### Unchanged
|
||||
- `BINARY_VERSION` stays at `firefox-7`. Python-only release; no new Firefox build was needed.
|
||||
|
||||
## [0.1.7] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "invisible-playwright"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
|
|
@ -289,13 +289,29 @@ _BASELINE: Dict[str, Any] = {
|
|||
"network.dns.echconfig.enabled": False,
|
||||
"network.dns.use_https_rr_as_altsvc": False,
|
||||
|
||||
# === A/B VARIANT B: Fission disabled ===
|
||||
# Force single content-process model (e10s only, no BC outer/inner split).
|
||||
# Diagnostic for the FF150 BC-swap theory: if peet_ws/fppro/sannysoft
|
||||
# work with this off, the Juggler FF146 baseline breaks specifically on
|
||||
# cross-process navigation tracking.
|
||||
# === Fission / site-isolation disabled (FF146 Playwright parity) ===
|
||||
# Force a single content-process model. Three knobs are required in FF150:
|
||||
# upstream Playwright Firefox (FF146-based) only needed fission.autostart=False
|
||||
# because FF146's default isolation strategy was looser. FF150 ships with
|
||||
# fission.webContentIsolationStrategy=1 (IsolateEverything) which still
|
||||
# site-isolates cross-origin iframes into separate `webIsolated` content
|
||||
# processes EVEN WHEN fission.autostart is False. From the parent process's
|
||||
# point of view, those iframes get a Juggler Frame placeholder with no
|
||||
# docShell, no URL, and an execution context that wraps the wrong global,
|
||||
# so frame.evaluate() fails with cross-origin SOP errors and
|
||||
# element_handle.content_frame() returns None.
|
||||
#
|
||||
# Pinning the strategy to 0 keeps every cross-origin web iframe in the
|
||||
# parent's content process, where the Juggler code paths from the FF146
|
||||
# era expect them. processCount.webIsolated=1 is kept as belt-and-suspenders
|
||||
# in case some path still classifies an origin as webIsolated despite the
|
||||
# strategy change. It costs nothing to leave.
|
||||
#
|
||||
# See issue #20 + tests/test_cross_origin_iframe.py for the regression
|
||||
# sentinel that catches a future A/B flipping these back.
|
||||
"fission.autostart": False,
|
||||
"fission.autostart.session": False,
|
||||
"fission.webContentIsolationStrategy": 0, # IsolateNothing
|
||||
"dom.ipc.processCount.webIsolated": 1,
|
||||
|
||||
|
||||
|
|
@ -385,19 +401,19 @@ _WIN_VIRT_DESKTOP_WORKAROUNDS: Dict[str, Any] = {
|
|||
# restores hardware compositor + functional WebGL on alt desktops.
|
||||
"security.sandbox.gpu.level": 0,
|
||||
# Same root cause as above, content process side. Wrapper repo issue #18
|
||||
# (id.sky.com tab crash). Sandbox content level > 4 puts content processes
|
||||
# on the sandbox's own kAlternateWinstation (see
|
||||
# security/sandbox/win/src/sandboxbroker/sandboxBroker.cpp line 1113-1114:
|
||||
# (tab crash on cross-process navigation under headless=True). Sandbox
|
||||
# content level > 4 puts content processes on the sandbox's own
|
||||
# kAlternateWinstation (see security/sandbox/win/src/sandboxbroker/
|
||||
# sandboxBroker.cpp line 1113-1114:
|
||||
# `if (aSandboxLevel > 4) config->SetDesktop(kAlternateWinstation)`).
|
||||
# Combined with our CreateDesktop alt-desktop, that puts browser process
|
||||
# and content processes on DIFFERENT desktops. Cross-process navigation
|
||||
# (Adobe AppMeasurement → new origin → new content process on a new
|
||||
# desktop) then fails window parenting between parent and child → content
|
||||
# then fails window parenting between parent and child, the content
|
||||
# process exits cleanly (exitCode=0, signal=null) and Playwright fires
|
||||
# page.on('crash') ~10s after page load. Lowering content sandbox to 4
|
||||
# keeps content processes on the same desktop as the browser process,
|
||||
# which is what we want here (and is still tight enough — level 4
|
||||
# blocks file/registry write, network calls, hardware access).
|
||||
# which is what we want here (still tight enough — level 4 blocks
|
||||
# file/registry write, network calls, hardware access).
|
||||
"security.sandbox.content.level": 4,
|
||||
}
|
||||
|
||||
|
|
|
|||
295
tests/test_cross_origin_iframe.py
Normal file
295
tests/test_cross_origin_iframe.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"""Regression tests for cross-origin / cross-process iframe interaction.
|
||||
|
||||
History: wrapper repo issue #20 reported that a third-party cookie
|
||||
consent iframe was completely unreachable from Playwright in 0.1.7 —
|
||||
``element_handle.content_frame()`` returned ``None``, ``frame.evaluate()``
|
||||
threw cross-origin SOP errors, and ``frame_locator().click()`` timed
|
||||
out.
|
||||
|
||||
Root cause was a missing pref. FF150 ships with
|
||||
``fission.webContentIsolationStrategy=1`` (IsolateEverything), which
|
||||
site-isolates cross-origin iframes into separate webIsolated content
|
||||
processes even when ``fission.autostart=False``. The Juggler code paths
|
||||
inherited from the FF146 era assume same-process iframes. The wrapper's
|
||||
``_BASELINE`` now pins the pref to 0 (IsolateNothing).
|
||||
|
||||
These tests exist so a future Firefox upgrade or a fingerprint A/B
|
||||
that flips this pref by accident cannot ship without a red CI signal.
|
||||
|
||||
Layers:
|
||||
* ``unit`` — ``_BASELINE`` contains the pref with the right value. No browser.
|
||||
* ``e2e`` — launch the real binary against a LOCAL HTTP harness on
|
||||
``127.0.0.1`` (two ports = two SOP origins) and verify the
|
||||
four protocol operations that regressed: frame URL tracking,
|
||||
``handle.content_frame()``, ``frame.evaluate()``, and
|
||||
``frame_locator(...).locator(...)`` element resolution.
|
||||
|
||||
The e2e tests run entirely offline. They never call out to a real site;
|
||||
the cross-origin shape is reproduced with two local HTTP servers on
|
||||
random free ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
from invisible_playwright.prefs import _BASELINE, translate_profile_to_prefs
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Unit layer — fast, no browser, runs on every CI
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_baseline_pins_web_content_isolation_strategy_to_zero():
|
||||
"""Regression sentinel.
|
||||
|
||||
``fission.webContentIsolationStrategy`` MUST be 0 (IsolateNothing).
|
||||
The FF150 default is 1 (IsolateEverything), which site-isolates
|
||||
cross-origin iframes into separate webIsolated content processes
|
||||
and breaks Playwright frame tracking from the parent process.
|
||||
"""
|
||||
assert _BASELINE["fission.webContentIsolationStrategy"] == 0, (
|
||||
"fission.webContentIsolationStrategy must be 0 (IsolateNothing). "
|
||||
"If you bumped it for an A/B, cross-origin iframes will appear "
|
||||
"in page.frames with empty URLs and content_frame() will return "
|
||||
"None — see the changelog entry that introduced this test."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_baseline_keeps_fission_autostart_off():
|
||||
"""Belt for the suspenders above. All three prefs are required."""
|
||||
assert _BASELINE["fission.autostart"] is False
|
||||
assert _BASELINE["fission.autostart.session"] is False
|
||||
assert _BASELINE["dom.ipc.processCount.webIsolated"] == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translated_profile_propagates_isolation_strategy():
|
||||
"""The fix must survive translate_profile_to_prefs, not just live in _BASELINE."""
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["fission.webContentIsolationStrategy"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_extra_prefs_override_can_break_isolation_only_explicitly():
|
||||
"""If a caller wants to A/B isolation, they have to set it explicitly.
|
||||
The wrapper does not silently flip it back on.
|
||||
"""
|
||||
p = generate_profile(seed=42)
|
||||
prefs_default = translate_profile_to_prefs(p)
|
||||
assert prefs_default["fission.webContentIsolationStrategy"] == 0
|
||||
|
||||
prefs_ab = translate_profile_to_prefs(
|
||||
p, extra_prefs={"fission.webContentIsolationStrategy": 1}
|
||||
)
|
||||
assert prefs_ab["fission.webContentIsolationStrategy"] == 1
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# E2E layer — needs cached binary + bind to localhost ports
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
class _SilentHandler(BaseHTTPRequestHandler):
|
||||
"""Suppress per-request access logging so pytest output stays clean."""
|
||||
PAYLOAD = b"" # set per-instance via subclassing
|
||||
|
||||
def log_message(self, *_a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.PAYLOAD)
|
||||
|
||||
|
||||
def _serve(payload: bytes, port: int) -> HTTPServer:
|
||||
"""Start an HTTP server on 127.0.0.1:port serving ``payload`` on every GET."""
|
||||
handler_cls = type(
|
||||
"_H", (_SilentHandler,), {"PAYLOAD": payload}
|
||||
)
|
||||
srv = HTTPServer(("127.0.0.1", port), handler_cls)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
return srv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_origin_harness():
|
||||
"""Spin up TWO local HTTP servers on different localhost ports.
|
||||
|
||||
Two ports = two distinct origins under SOP (same host, different port
|
||||
→ different origin). The parent page on port A embeds an iframe with
|
||||
src pointing at port B. Same cross-origin browsing-context shape as
|
||||
a parent-page-plus-third-party-iframe layout, fully offline.
|
||||
"""
|
||||
pa, pb = _free_port(), _free_port()
|
||||
parent_html = f"""<!doctype html><html><head><title>parent</title></head><body>
|
||||
<h1>parent</h1>
|
||||
<iframe id="ifr_plain" src="http://127.0.0.1:{pb}/child" width="300" height="120"></iframe>
|
||||
<iframe id="ifr_sandbox" src="http://127.0.0.1:{pb}/child" width="300" height="120"
|
||||
sandbox="allow-scripts allow-same-origin"></iframe>
|
||||
<iframe id="ifr_titled" src="http://127.0.0.1:{pb}/child" width="300" height="120"
|
||||
title="cross-origin titled iframe"></iframe>
|
||||
</body></html>""".encode("utf-8")
|
||||
child_html = b"""<!doctype html><html><body>
|
||||
<button id="ok">confirm</button>
|
||||
<button class="btn-primary">primary</button>
|
||||
<script>document.getElementById('ok').addEventListener('click', () => document.title = 'clicked')</script>
|
||||
</body></html>"""
|
||||
sa = _serve(parent_html, pa)
|
||||
sb = _serve(child_html, pb)
|
||||
try:
|
||||
yield {"parent_url": f"http://127.0.0.1:{pa}/", "child_origin": f"http://127.0.0.1:{pb}"}
|
||||
finally:
|
||||
sa.shutdown()
|
||||
sb.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def firefox_binary():
|
||||
"""Locate the cached patched Firefox binary or skip."""
|
||||
from invisible_playwright.constants import BINARY_ENTRY_REL
|
||||
if sys.platform not in BINARY_ENTRY_REL:
|
||||
pytest.skip(f"unsupported platform: {sys.platform}")
|
||||
from invisible_playwright.download import cache_dir_for_version
|
||||
entry = cache_dir_for_version() / BINARY_ENTRY_REL[sys.platform]
|
||||
if not entry.exists():
|
||||
pytest.skip(
|
||||
"patched Firefox binary not cached; run `invisible-playwright fetch` "
|
||||
"to enable E2E tests"
|
||||
)
|
||||
return str(entry)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_url_appears_in_page_frames(firefox_binary, cross_origin_harness):
|
||||
"""``page.frames`` must list the cross-origin iframe with its real URL.
|
||||
|
||||
Before the pref fix, the URL came back as '' because the navigation
|
||||
observer for the iframe fired in a different content process than
|
||||
the parent's FrameTree was registered in.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
urls = [f.url for f in page.frames]
|
||||
assert any(cross_origin_harness["child_origin"] in (u or "") for u in urls), (
|
||||
f"no frame had the child origin in its URL; page.frames urls = {urls!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_content_frame_resolves(firefox_binary, cross_origin_harness):
|
||||
"""``handle.content_frame()`` must return a Frame (not None) for every
|
||||
cross-origin iframe shape we care about: plain, sandboxed, titled.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
for sel in ("iframe#ifr_plain", "iframe#ifr_sandbox", "iframe#ifr_titled"):
|
||||
handle = page.query_selector(sel)
|
||||
assert handle is not None, f"{sel!r} not found in DOM"
|
||||
cf = handle.content_frame()
|
||||
assert cf is not None, f"{sel!r}: content_frame() returned None"
|
||||
assert cross_origin_harness["child_origin"] in (cf.url or ""), (
|
||||
f"{sel!r}: content_frame().url = {cf.url!r}, "
|
||||
f"expected child origin {cross_origin_harness['child_origin']!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_evaluate_returns_real_values(firefox_binary, cross_origin_harness):
|
||||
"""``frame.evaluate()`` inside the cross-origin iframe must work.
|
||||
|
||||
Pre-fix: every evaluate failed with a cross-origin SOP error because
|
||||
the iframe ended up with a stale/wrong execution context.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
cf = page.query_selector("iframe#ifr_plain").content_frame()
|
||||
assert cf is not None
|
||||
href = cf.evaluate("() => location.href")
|
||||
assert cross_origin_harness["child_origin"] in href
|
||||
title = cf.evaluate("() => document.title")
|
||||
assert isinstance(title, str)
|
||||
n_buttons = cf.evaluate("() => document.querySelectorAll('button').length")
|
||||
assert n_buttons == 2
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_frame_locator_resolves_button(firefox_binary, cross_origin_harness):
|
||||
"""``frame_locator(...).locator(...)`` must reach the button inside the iframe."""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
|
||||
for selector in ("button#ok", "button.btn-primary"):
|
||||
cnt = page.frame_locator("iframe#ifr_plain").locator(selector).count()
|
||||
assert cnt == 1, f"locator({selector!r}) found {cnt} elements (expected 1)"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_dispatch_event_click_works(firefox_binary, cross_origin_harness):
|
||||
"""End-to-end interaction via ``dispatch_event`` must succeed.
|
||||
|
||||
Plain ``.click()`` can trip Playwright's actionability heuristic on
|
||||
some third-party UIs (same on vanilla Playwright Firefox — not our
|
||||
regression), but ``dispatch_event('click')`` always works once the
|
||||
iframe is reachable.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
|
||||
page.frame_locator("iframe#ifr_plain").locator("button#ok").dispatch_event(
|
||||
"click", timeout=4_000
|
||||
)
|
||||
cf = page.query_selector("iframe#ifr_plain").content_frame()
|
||||
assert cf.evaluate("() => document.title") == "clicked"
|
||||
Loading…
Add table
Add a link
Reference in a new issue