Bug 1900222 - Avoid getting stuck on initial about:blank r=zombie

This patch fixes the issue by injecting immediately when a content
script execution is scheduled for an initial about:blank document.
This case is only reachable through dynamic content script execution
APIs, and not through declarative content scripts. The latter is out of
scope for this bug and tracked separately at bug 1415539 + bug 1486036.

An alternative solution could have been to not inject, e.g. by ignoring
the frame or throwing an error. This would however be unexpected to an
an extension developer whose intention is to run a code snippet in all
frames that are reachable by web pages (which works in Chrome).

Differential Revision: https://phabricator.services.mozilla.com/D212987
This commit is contained in:
Rob Wu
2024-06-15 00:44:27 +00:00
parent a9b895727c
commit e07aba7091
5 changed files with 228 additions and 16 deletions

View File

@@ -478,13 +478,20 @@ class Script {
}
try {
if (this.runAt === "document_end") {
await promiseDocumentReady(window.document);
} else if (this.runAt === "document_idle") {
await Promise.race([
promiseDocumentIdle(window),
promiseDocumentLoaded(window.document),
]);
// In case of initial about:blank documents, inject immediately without
// awaiting the runAt logic in the blocks below, to avoid getting stuck
// due to https://bugzilla.mozilla.org/show_bug.cgi?id=1900222#c7
// This is only relevant for dynamic code execution because declarative
// content scripts do not run on initial about:blank - bug 1415539).
if (!window.document.isInitialDocument) {
if (this.runAt === "document_end") {
await promiseDocumentReady(window.document);
} else if (this.runAt === "document_idle") {
await Promise.race([
promiseDocumentIdle(window),
promiseDocumentLoaded(window.document),
]);
}
}
return this.inject(context, reportExceptions);

View File

@@ -0,0 +1,14 @@
<!DOCTYPE HTML>
<meta charset="utf-8">
Load a same-origin iframe (http://mochi.test:8888, https://mochi.test/, http://mochi.xorigin-test:8888, etc.).<br>
<iframe src="file_sample.html"></iframe>
<script>
"use strict";
// Dereference frames[0] to ensure the creation of the frame's window context.
// Without this, the frame's browsingContext.currentWindowGlobal is null in the
// parent on Android with --disable-fission (bug 1902709).
void frames[0];
console.log("This is " + document.URL + " and opener=" + opener);
</script>

View File

@@ -57,6 +57,7 @@ support-files = [
"file_with_iframe_sandbox.html",
"file_with_subframes_and_embed.html",
"file_with_xorigin_frame.html",
"file_with_same_origin_frame.html",
"head.js",
"head_cookies.js",
"!/dom/notification/test/mochitest/MockAlertsService.js",
@@ -388,6 +389,8 @@ skip-if = [
["test_ext_scripting_executeScript_null_principal.html"]
["test_ext_scripting_executeScript_slow_frame.html"]
["test_ext_scripting_executeScript_world.html"]
["test_ext_scripting_insertCSS.html"]

View File

@@ -0,0 +1,151 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>Tests scripting.executeScript() with allFrames:true or frameId option, edge cases</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
<script type="text/javascript" src="head.js"></script>
<link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
</head>
<body>
<script type="text/javascript">
"use strict";
let gRequestThrottlerExt = null;
async function throttleAllIframeLoads() {
is(gRequestThrottlerExt, null, "throttleAllIframeLoads - one at a time");
gRequestThrottlerExt = ExtensionTestUtils.loadExtension({
manifest: {
permissions: ["webRequest", "webRequestBlocking"],
host_permissions: ["*://*/*"],
},
background() {
let neverResolvingPromise = new Promise(() => {});
browser.webRequest.onBeforeRequest.addListener(
details => {
browser.test.log(`(Indefinitely) delaying load of ${details.url}`);
return neverResolvingPromise;
},
{ urls: ["*://*/*"], types: ["sub_frame"] },
["blocking"]
);
},
});
await gRequestThrottlerExt.startup();
}
async function stopThrottlingAllIframeLoads() {
await gRequestThrottlerExt.unload();
gRequestThrottlerExt = null;
}
async function openTabAndAwaitLoad(url) {
info(`openTabAndAwaitLoad: ${url}`);
// We cannot use AppTestDelegate.openNewForegroundTab in this test because it
// resolves only when all subresources have been loaded, but we are
// intentionally delaying iframe loads in this test.
let win = window.open(url);
const browsingContextId = SpecialPowers.wrap(win).browsingContext.id;
await SimpleTest.promiseWaitForCondition(
() =>
SpecialPowers.spawnChrome(
[browsingContextId],
bcId => BrowsingContext.get(bcId).children.length
),
"Parent process should detect frame in " + url
);
return win;
}
async function do_test_executeScript_with_slow_frame({
injectImmediately = false,
targetSlowFrame = false, // if false, then allFrames:true is used.
}) {
await throttleAllIframeLoads();
let url = new URL("file_with_same_origin_frame.html", document.baseURI).href;
let win = await openTabAndAwaitLoad(url);
let extension = ExtensionTestUtils.loadExtension({
manifest: {
description: JSON.stringify({ injectImmediately, targetSlowFrame, url }),
permissions: ["scripting", "tabs"],
host_permissions: ["*://*/*"],
},
async background() {
let { injectImmediately, targetSlowFrame, url } = JSON.parse(
browser.runtime.getManifest().description
);
// Replace port to work around bug 1468162.
let tabs = await browser.tabs.query({ url: url.replace(/:\d+\//, "/") });
browser.test.assertEq(1, tabs.length, "Found tab of current test");
const target = { tabId: tabs[0].id, allFrames: !targetSlowFrame };
if (targetSlowFrame) {
let [ { result: frameId }] = await browser.scripting.executeScript({
target: { tabId: tabs[0].id },
func: () => browser.runtime.getFrameId(frames[0]),
injectImmediately: true,
});
target.frameIds = [frameId];
}
browser.test.log(`executeScript target is ${JSON.stringify(target)}`);
let results = await browser.scripting.executeScript({
target,
injectImmediately,
func: () => document.URL,
});
// The top document is file_with_same_origin_frame.html and the only
// child frame is the pending load of file_sample.html.
if (!targetSlowFrame) {
// with allFrames:true, the first result is from the top document.
browser.test.assertEq(results[0]?.result, url, "Got right tab");
}
let { error, result } = results[targetSlowFrame ? 0 : 1] || {};
browser.test.assertEq(error?.message, undefined, "No error");
browser.test.assertEq(result, "about:blank", "Got result");
browser.test.notifyPass();
},
});
await extension.startup();
await extension.awaitFinish();
await extension.unload();
win.close();
await stopThrottlingAllIframeLoads();
}
// Tests scripting.executeScript with allFrames:true and injectImmediately:true
// while one of the frames is still loading (and at initial about:blank).
add_task(async function test_executeScript_allFrames_injectImmediately_true() {
await do_test_executeScript_with_slow_frame({ injectImmediately: true });
});
// Tests scripting.executeScript with allFrames:true while one of the frames is
// still loading (and at initial about:blank).
add_task(async function test_executeScript_allFrames_injectImmediately_false() {
await do_test_executeScript_with_slow_frame({ injectImmediately: false });
});
// Tests scripting.executeScript with frameId of a frame that is still loading
// (and at initial about:blank).
add_task(async function test_executeScript_frameId_slow_frame() {
// Note: this tests with injectImmediately:false. We do not separately test
// with injectImmediately:true because if there were to be any failure with
// its implementation, then it should have been caught by
// test_executeScript_allFrames_injectImmediately_true.
await do_test_executeScript_with_slow_frame({ targetSlowFrame: true });
});
</script>
</body>
</html>

View File

@@ -64,6 +64,7 @@ registerCleanupFunction(() => {
// Set up detector to detect content script injections in iframes.
async function setupScriptInjectionDetector(contentPage) {
await contentPage.spawn([], () => {
info(`setupScriptInjectionDetector: pid=${Services.appinfo.processID}`);
const { ExtensionProcessScript } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionProcessScript.sys.mjs"
);
@@ -74,11 +75,25 @@ async function setupScriptInjectionDetector(contentPage) {
}
const pendingInjections = [];
let callCounter = 0;
// Note: we overwrite the ExtensionProcessScript methods without a way to
// undo that in this test.
const { loadContentScript } = ExtensionProcessScript;
ExtensionProcessScript.loadContentScript = (contentScript, window) => {
// This logging is not strictly needed, but debugging test failures and
// timeouts is near-impossible without this logging.
const logPrefix = `loadContentScript #${++callCounter} pid=${
Services.appinfo.processID
}`;
dump(
`${logPrefix} START runAt=${contentScript.runAt} readyState=${window.document?.readyState} origin=${window.origin} URL=${window.document?.URL} frameHTML=${window.frameElement?.outerHTML} isInitialDocument=${window.document?.isInitialDocument}\n`
);
let promise = loadContentScript(contentScript, window);
// In this test we are only interested in iframes. Test coverage for
// top-level about:blank is already provided by:
// toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
if (window.parent !== window) {
let url = window.document?.URL ?? "(no document???)";
let runAt = contentScript.runAt;
@@ -87,11 +102,17 @@ async function setupScriptInjectionDetector(contentPage) {
promise.then(
() => {
injectionDescription.returnStatus = "resolved";
dump(`${logPrefix} IFRAME RESOLVED\n`);
},
() => {
e => {
injectionDescription.returnStatus = "rejected";
dump(`${logPrefix} IFRAME REJECTED with: ${e}\n`);
}
);
} else {
promise.finally(() => {
dump(`${logPrefix} TOP SETTLED\n`);
});
}
return promise;
};
@@ -104,6 +125,7 @@ async function setupScriptInjectionDetector(contentPage) {
async function getPendingScriptInjections(contentPage) {
return contentPage.spawn([], () => {
info(`getPendingScriptInjections: pid=${Services.appinfo.processID}`);
const { ExtensionProcessScript } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionProcessScript.sys.mjs"
);
@@ -241,22 +263,36 @@ add_task(async function test_frame_unload_before_execute() {
let contentPage = await ExtensionTestUtils.loadContentPage(URL_INITIAL);
await setupScriptInjectionDetector(contentPage);
await contentPage.spawn([], () => {
info(`Adding frame: pid=${Services.appinfo.processID}`);
const { document } = this.content.wrappedJSObject;
let f = document.createElement("iframe");
f.id = "neverloading_frame";
f.src = "/neverloading.html";
document.body.append(f);
// We need to dereference the content window, because otherwise the document
// won't be created and the test gets stuck waiting for donePromise below,
// on Android with --disable-fission (bug 1902709).
void f.contentWindow;
});
const extension = loadTestExtensionWithContentScripts();
const seenScripts = [];
extension.onMessage("got_frame", (runAt, url) => {
seenScripts.push({ runAt, url });
const donePromise = new Promise(resolve => {
extension.onMessage("got_frame", (runAt, url) => {
seenScripts.push({ runAt, url });
if (runAt === "document_idle") {
resolve();
}
});
});
await extension.startup();
await extension.awaitMessage("contentscript_in_top");
info("Detected content script attempt in top doc, going to remove the frame");
info("Detected content script attempt in top doc, waiting for frame");
await donePromise;
info("Detected content script execution in frame, going to remove the frame");
await contentPage.spawn([], async () => {
info(`Removing frame: pid=${Services.appinfo.processID}`);
const { TestUtils } = ChromeUtils.importESModule(
"resource://testing-common/TestUtils.sys.mjs"
);
@@ -280,16 +316,18 @@ add_task(async function test_frame_unload_before_execute() {
// in the usual case, which is tested by testLoadContentScripts. See
// the comment about "about:blank" and bug 1415539 + bug 1486036.
{ runAt: "document_start", url: "about:blank" },
{ runAt: "document_end", url: "about:blank" },
{ runAt: "document_idle", url: "about:blank" },
],
"Accounted for all content script executions"
);
Assert.deepEqual(
await getPendingScriptInjections(contentPage),
[
// Regression test for bug 1900222: returnStatus should not be "pending".
{ returnStatus: "resolved", runAt: "document_start", url: "about:blank" },
{ returnStatus: "pending", runAt: "document_end", url: "about:blank" },
// Note: document_idle is missing because it is blocked on document_end.
//
{ returnStatus: "resolved", runAt: "document_end", url: "about:blank" },
{ returnStatus: "resolved", runAt: "document_idle", url: "about:blank" },
// NOTE: The appearance of initial about:blank above is inconsistent with
// the usual injection behavior (see testLoadContentScripts, the mention
// of "about:blank" and bug 1415539 + bug 1486036 ).
@@ -297,8 +335,7 @@ add_task(async function test_frame_unload_before_execute() {
// We haven't loaded any other frame, so we should not have seen any
// other content script execution attempt.
],
"TODO: There should not be any pending content script injections"
// ^ The test expectation is incorrect due to a variant of bug 1900222.
"There should not be any pending content script injections"
);
await contentPage.close();
await extension.unload();