diff --git a/CHANGELOG.md b/CHANGELOG.md index b7106ab..8a477de 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index 6261b12..cf0cb08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/invisible_playwright/prefs.py b/src/invisible_playwright/prefs.py index 496fd04..4f0a15d 100644 --- a/src/invisible_playwright/prefs.py +++ b/src/invisible_playwright/prefs.py @@ -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, } diff --git a/tests/test_cross_origin_iframe.py b/tests/test_cross_origin_iframe.py new file mode 100644 index 0000000..8be39ac --- /dev/null +++ b/tests/test_cross_origin_iframe.py @@ -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"""