tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 6a3dc6db5ef4b986dec4256c0964e65223f2b90c
parent 9ea357021925d50a7d3c203011a0c75d54cbad04
Author: Simon Farre <sfarre@mozilla.com>
Date:   Mon, 15 Dec 2025 09:03:40 +0000

Bug 2002289 Fix OOP-frame JSWA fullscreen problem r=edgar

This patch fixes a problem where we have fullscreened a subtree of
documents and now a request is made to fullscreen an element further
down in the tree, which may be out of process, with respect to the
already fullscreened elements and documents.

The bug triggers, because a check in the parent proess will see that the
browser tab, already has a chrome element that is fullscreen and then
aborts. We must also not do any actual work when we hit this issue,
because everything is already fullscreen.

The fix is to resume the JSWA "messaging chain" without doing any
Fullscreen API work and only do so when the in-process-requested
to-be-fullscreened-element is an iframe, or if we're the parent process,
otherwise the check should "fail" as normal and not do anything.

Added wpt-like tests. I guess there's a case to be made to change the
test testing/web-platform/tests/fullscreen/api/element-request-fullscreen-cross-origin.sub.html
to behave like the test I've provided here. It first requests
fullscreen inside B, which will make the body element go fullscreen,
then when a request goes for D (its body element), the iframe in B also
goes fullscreen and then the remainder of frames goes fullscreen. The
point being for us, that when doing fullscreen stuff and we're stuck on
JSWA, we must propagate the MozDOMFullscreen:Entered messages
properly, even if no work needs to be done.

Differential Revision: https://phabricator.services.mozilla.com/D274060

Diffstat:
Mdom/base/Document.cpp | 46+++++++++++++++++++++++++++++++++++++++++-----
Atesting/web-platform/mozilla/tests/fullscreen/api/element-request-fullscreen-cross-origin-multi-steps.sub.html | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/mozilla/tests/fullscreen/api/resources/recursive-iframe-fullscreen.html | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/mozilla/tests/fullscreen/api/resources/trusted-click.js | 25+++++++++++++++++++++++++
4 files changed, 311 insertions(+), 5 deletions(-)

diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp @@ -16408,6 +16408,24 @@ const char* Document::GetFullscreenError(CallerType aCallerType) { return nullptr; } +// Informs JSWA Fullscreen implementation to resume via sending +// "MozDOMFullscreen:Entered". +static inline void PropagateFullscreenRequest(Document* aDoc, + Element* aElement) { + nsContentUtils::DispatchEventOnlyToChrome( + aDoc, aElement, u"MozDOMFullscreen:Entered"_ns, CanBubble::eYes, + Cancelable::eNo, /* DefaultAction */ nullptr); +} + +static bool ElementIsRemoteFrame(Element* aElement) { + MOZ_ASSERT(aElement); + RefPtr<nsFrameLoader> loader; + if (RefPtr<nsFrameLoaderOwner> loaderOwner = do_QueryObject(aElement)) { + loader = loaderOwner->GetFrameLoader(); + } + return loader && loader->IsRemoteFrame(); +} + bool Document::FullscreenElementReadyCheck(FullscreenRequest& aRequest) { Element* elem = aRequest.Element(); // Strictly speaking, this isn't part of the fullscreen element ready @@ -16416,7 +16434,19 @@ bool Document::FullscreenElementReadyCheck(FullscreenRequest& aRequest) { // should change and no event should be dispatched, but we still need // to resolve the returned promise. Element* fullscreenElement = GetUnretargetedFullscreenElement(); - if (elem == fullscreenElement) { + if (NS_WARN_IF(elem == fullscreenElement)) { + // But this introduces behavior that we now need to account for; + // because we can have arbitrary depth of OOP-frames, we may hit this check + // for a process that already is fullscreen, e.g. the parent process. + // If the target element is a frame or we're the parent process, just resume + // the JS Window Actor messaging without doing any more work. + // We know for sure, that the document must be fullscreened already, so + // there is no request to the OS for fullscreen that needs to be made, for + // instance. Note: this is just for JSWA not the platform-only fullscreen + // implementation. + if (ElementIsRemoteFrame(elem)) { + PropagateFullscreenRequest(this, elem); + } aRequest.MayResolvePromise(); return false; } @@ -16750,10 +16780,16 @@ bool Document::ApplyFullscreen(UniquePtr<FullscreenRequest> aRequest) { // notifying parent process to enter fullscreen. Note that chrome // code may also want to listen to MozDOMFullscreen:NewOrigin event // to pop up warning UI. - if (!previousFullscreenDoc) { - nsContentUtils::DispatchEventOnlyToChrome( - this, elem, u"MozDOMFullscreen:Entered"_ns, CanBubble::eYes, - Cancelable::eNo, /* DefaultAction */ nullptr); + // We also need to propagate the message in the JSWA message chain, + // so that if out of process sub-frames, that has requested fs + // gets notified to resume it's request. We can know, that the request did not + // originate from this process, if the JS promise is null. + if (!aRequest->GetPromise() || !previousFullscreenDoc) { + MOZ_ASSERT( + (previousFullscreenDoc && + ElementIsRemoteFrame(child->GetUnretargetedFullscreenElement())) || + !previousFullscreenDoc); + PropagateFullscreenRequest(this, elem); } // The origin which is fullscreen gets changed. Trigger an event so diff --git a/testing/web-platform/mozilla/tests/fullscreen/api/element-request-fullscreen-cross-origin-multi-steps.sub.html b/testing/web-platform/mozilla/tests/fullscreen/api/element-request-fullscreen-cross-origin-multi-steps.sub.html @@ -0,0 +1,157 @@ +<!DOCTYPE html> +<title> + Element#requestFullscreen() works properly with a tree of cross-origin iframes, with multiple requests +</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> + +<body> + <script> + let childFrame = null; + function waitFor(action, frameName) { + return new Promise((resolve) => { + window.addEventListener("message", function listener(e) { + if (e.data.action === action && e.data.name === frameName) { + window.removeEventListener("message", listener); + resolve(event.data); + } + }); + }); + } + + function compare_report(report, frame, expectedIndex) { + assert_array_equals(report.events, frame.expectedEvents[expectedIndex], `compare events for ${frame.name}`); + assert_equals(report.fullscreenElementIsNull, frame.expectedNullFullscreenElement[expectedIndex], `frame ${frame.name} has expected fullscreen element`); + } + + const iframes = [ + { + name: "A", + src: "http://{{hosts[][]}}:{{ports[http][0]}}/_mozilla/fullscreen/api/resources/recursive-iframe-fullscreen.html?a", + allow_fullscreen: true, + expectedEvents: [["fullscreenchange"], []], + expectedNullFullscreenElement: [false, false] + }, + { // first check should see its body go fs, 2nd should see it's <iframe> go fs + name: "B", + src: "http://{{hosts[alt][]}}:{{ports[http][1]}}/_mozilla/fullscreen/api/resources/recursive-iframe-fullscreen.html?b", + allow_fullscreen: true, + expectedEvents: [["fullscreenchange"], ["fullscreenchange"]], + expectedNullFullscreenElement: [false, false] + }, + { + name: "C", + src: "http://{{hosts[][www]}}:{{ports[http][0]}}/_mozilla/fullscreen/api/resources/recursive-iframe-fullscreen.html?c", + allow_fullscreen: true, + expectedEvents: [[], ["fullscreenchange"]], + expectedNullFullscreenElement: [true, false] + }, + { + name: "D", + src: "http://{{hosts[][]}}:{{ports[http][1]}}/_mozilla/fullscreen/api/resources/recursive-iframe-fullscreen.html?d", + allow_fullscreen: true, + expectedEvents: [[],["fullscreenchange"]], + expectedNullFullscreenElement: [true, false] + }, + { + name: "E", + src: "http://{{hosts[][]}}:{{ports[http][0]}}/_mozilla/fullscreen/api/resources/recursive-iframe-fullscreen.html?e", + allow_fullscreen: true, + expectedEvents: [[],[]], + expectedNullFullscreenElement: [true, true] + }, + ]; + + promise_setup(async () => { + // Add the first iframe. + const iframeDetails = iframes[0]; + childFrame = document.createElement("iframe"); + childFrame.allow = iframeDetails.allow_fullscreen ? "fullscreen" : ""; + childFrame.name = iframeDetails.name; + childFrame.style.width = "100%"; + childFrame.style.height = "100%"; + childFrame.src = iframeDetails.src; + await new Promise((resolve) => { + childFrame.onload = resolve; + document.body.appendChild(childFrame); + }); + + // Create the nested iframes. + for (let i = 1; i < iframes.length; i++) { + const parentName = iframes[i - 1].name; + const details = iframes[i]; + childFrame.contentWindow.postMessage( + { action: "addIframe", iframe: details, name: parentName }, + "*" + ); + await waitFor("load", details.name); + } + }); + + promise_test(async (t) => { + t.add_cleanup(async () => { + if (document.fullscreenElement) { + await new Promise((resolve) => { + document.addEventListener("fullscreenchange", resolve, { once: true }); + document.exitFullscreen(); + }); + } + if (childFrame) { + childFrame.remove(); + } + }); + document.onfullscreenerror = t.unreached_func( + "fullscreenerror event fired" + ); + + const childFrame = document.querySelector("iframe[name=A]"); + + // request fullscreen by trusted click in `name` + // and wait until we've seen fullscreen events from + // `expectedFrames`. This is to handle intermittent failures + // where we check results before the request has completed entirely. + const requestFullscreenIn = (name, expectedFrames) => { + return new Promise((resolve) => { + const pending = new Set(expectedFrames); + + function listener(e) { + if (e.data.action === "fsEvent" && pending.has(e.data.name)) { + pending.delete(e.data.name); + + if (pending.size === 0) { + window.removeEventListener("message", listener); + resolve(); + } + } + } + + window.addEventListener("message", listener); + childFrame.contentWindow.postMessage( + { action: "requestFullscreen", name }, + "*" + ); + }); + }; + + const verifyResult = async (expectedResultIndex) => { + for (const frame of iframes) { + const data = { + action: "requestReport", + name: frame.name, + }; + childFrame.contentWindow.postMessage(data, "*"); + const { report } = await waitFor("report", frame.name); + compare_report(report, frame, expectedResultIndex); + } + } + + await requestFullscreenIn("B", ["A", "B"]); + await verifyResult(0); + + await requestFullscreenIn("D", ["B", "C", "D"]); + await verifyResult(1); + }, "Element#requestFullscreen() works properly with a tree of cross-origin iframes"); + </script> +</body> diff --git a/testing/web-platform/mozilla/tests/fullscreen/api/resources/recursive-iframe-fullscreen.html b/testing/web-platform/mozilla/tests/fullscreen/api/resources/recursive-iframe-fullscreen.html @@ -0,0 +1,88 @@ +<!DOCTYPE html> +<meta charset="utf-8" /> +<title>Recursive IFrame Fullscreen API success reporter</title> +<body> + <script src="/resources/testdriver.js"></script> + <script src="/resources/testdriver-vendor.js"></script> + <script src="trusted-click.js"></script> + <script> + let child_frame = null; + let events = []; + + document.onfullscreenchange = () => { + window.top.postMessage({ action: "fsEvent", name: window.name }, "*"); + events.push("fullscreenchange"); + }; + + document.onfullscreenerror = () => { + window.top.postMessage({ action: "fsEvent", name: window.name }, "*"); + events.push("fullscreenerror"); + }; + + function send_report() { + window.top.postMessage( + { + name: window.name, + action: "report", + report: { + frame: window.name, + fullscreenElementIsNull: document.fullscreenElement === null, + events, + }, + }, + "*" + ); + events = []; + } + + async function create_child_frame({ src, name, allow_fullscreen }) { + child_frame = document.createElement("iframe"); + child_frame.allow = allow_fullscreen ? "fullscreen" : ""; + child_frame.name = name; + child_frame.style.width = "100%"; + child_frame.style.height = "100%"; + document.body.appendChild(child_frame); + await new Promise((resolve) => { + child_frame.addEventListener("load", resolve, { once: true }); + child_frame.src = src; + }); + window.top.postMessage({ action: "load", name }, "*"); + } + + async function go_fullscreen() { + await trusted_click(document.body); + let error; + try { + await document.body.requestFullscreen(); + } catch (err) { + error = err.name; + } finally { + window.top.postMessage( + { action: "requestFullscreen", name: window.name, error }, + "*" + ); + } + } + + window.addEventListener("message", async (e) => { + // Massage is not for us, try to pass it on... + if (e.data.name !== window.name) { + child_frame?.contentWindow.postMessage(e.data, "*"); + return; + } + switch (e.data.action) { + case "requestReport": + send_report(); + break; + case "requestFullscreen": + await go_fullscreen(); + break; + case "addIframe": + await create_child_frame(e.data.iframe); + break; + default: + window.top.postMessage(e.data, "*"); + } + }); + </script> +</body> diff --git a/testing/web-platform/mozilla/tests/fullscreen/api/resources/trusted-click.js b/testing/web-platform/mozilla/tests/fullscreen/api/resources/trusted-click.js @@ -0,0 +1,25 @@ +/** + * Invokes callback from a trusted click event, avoiding interception by fullscreen element. + * + * @param {Element} container - Element where button will be created and clicked. + */ +function trusted_click(container = document.body) { + var document = container.ownerDocument; + var button = document.createElement("button"); + button.textContent = "click to continue test"; + button.style.display = "block"; + button.style.fontSize = "20px"; + button.style.padding = "10px"; + button.addEventListener("click", () => { + button.remove(); + }); + container.appendChild(button); + if (window.top !== window) test_driver.set_test_context(window.top); + // Race them for manually testing... + return Promise.race([ + test_driver.click(button), + new Promise((resolve) => { + button.addEventListener("click", resolve); + }), + ]); +}