"use strict"; const server = createHttpServer({ hosts: ["example.com", "example.net"] }); server.registerPathHandler("/dummy", () => {}); AddonTestUtils.init(this); AddonTestUtils.overrideCertDB(); const { ExtensionPermissions } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionPermissions.sys.mjs" ); async function grantUserScriptsPermission(extensionId) { // userScripts is optional-only, and we must grant it. See comment at // grantUserScriptsPermission in test_ext_userScripts_mv3_availability.js. await ExtensionPermissions.add(extensionId, { permissions: ["userScripts"], origins: [], }); } async function spawnPage(spawnFunc) { let contentPage = await ExtensionTestUtils.loadContentPage( "http://example.com/dummy" ); const results = await contentPage.spawn([], spawnFunc); await contentPage.close(); return results; } add_setup(async () => { await ExtensionTestUtils.startAddonManager(); }); add_task(async function default_USER_SCRIPT_world_behavior() { const extensionId = "@default_USER_SCRIPT_world_behavior"; await grantUserScriptsPermission(extensionId); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { browser_specific_settings: { gecko: { id: extensionId } }, manifest_version: 3, optional_permissions: ["userScripts"], host_permissions: ["*://example.com/*"], }, files: { "world_checker.js": () => { window.wrappedJSObject.results = []; // window.eval is not blocked and runs in MAIN world. // eslint-disable-next-line no-eval let resultsInMainWorld = window.eval("results"); // Unlike ISOLATED, USER_SCRIPT world cannot access extension APIs. resultsInMainWorld.push(typeof browser === "undefined"); // Unlike MAIN, USER_SCRIPT world's default CSP blocks eval. try { // eslint-disable-next-line no-eval eval("throw new Error('eval executed unexpectedly???')"); } catch (e) { resultsInMainWorld.push(e.message); } }, }, async background() { await browser.userScripts.register([ { id: "world global checker", matches: ["*://example.com/dummy"], js: [{ file: "world_checker.js" }], runAt: "document_start", world: "USER_SCRIPT", }, ]); browser.test.sendMessage("registered"); }, }); await extension.startup(); await extension.awaitMessage("registered"); const results = await spawnPage(() => this.content.wrappedJSObject.results); equal(results[0], true, "browser (extension APIs) should be undefined"); equal( results[1], "call to eval() blocked by CSP", "eval() should be blocked by default in the USER_SCRIPT world" ); await extension.unload(); }); add_task(async function multiple_scripts_share_same_default_world() { const extensionId = "@multiple_scripts_share_same_default_world"; await grantUserScriptsPermission(extensionId); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { browser_specific_settings: { gecko: { id: extensionId } }, manifest_version: 3, optional_permissions: ["userScripts"], host_permissions: ["*://example.com/*"], }, async background() { await browser.userScripts.register([ // The document_start script always runs first, which initializes the // middle string that the document_end scripts will prepend/append to, // as a sign of execution. // The expected value of wrappedJSObject.r below is independent of the // execution order of the two blocks of document_end scripts. Test // coverage on actual execution order of separate scripts is provided // further below by test_default_and_many_non_default_worldIds. { id: "first scripts", matches: ["*://example.com/dummy"], js: [ // All js within one script are guaranteed to run in order, and // they should run in the same default sandbox. { code: `var a = "";` }, { code: `a += "1";` }, { code: `a += "2";` }, { code: `a += "3";` }, { code: `a += "4";` }, { code: `a += "5";` }, // Prepends a to x, and expose in web page as variable "r". { code: `var x = x || ""; x = a + x; window.wrappedJSObject.r=x` }, ], runAt: "document_end", }, { id: "separate scripts", matches: ["*://example.com/dummy"], js: [ { code: `var b = "";` }, { code: `b += "6";` }, { code: `b += "7";` }, { code: `b += "8";` }, { code: `b += "9";` }, // Appends a to x, and expose in web page as variable "r". { code: `var x = x || ""; x = x + b; window.wrappedJSObject.r=x` }, ], runAt: "document_end", }, { id: "document_start, runs before other document_end scripts", matches: ["*://example.com/dummy"], // The other scripts prepend and append, and this becomes the middle. js: [{ code: `var x = x || "_"; window.wrappedJSObject.r=x` }], runAt: "document_start", }, ]); browser.test.sendMessage("registered"); }, }); await extension.startup(); await extension.awaitMessage("registered"); const result = await spawnPage(async () => { // We might observe page load while the document_end scripts are still // compiling. To avoid intermittent failures, wait for it to complete. await ContentTaskUtils.waitForCondition( // 10 is "12345_6789".length (expected value of r). () => this.content.wrappedJSObject.r?.length === 10, "Waiting for all user scripts to have completed running" ); let { x, r } = this.content.wrappedJSObject; return { x, r }; }); equal(result.x, undefined, "Web page cannot see vars from USER_SCRIPT world"); equal(result.r, "12345_6789", "All user scripts should share the same scope"); await extension.unload(); }); add_task(async function test_worldId_validation() { const extensionId = "@test_worldId_validation"; await grantUserScriptsPermission(extensionId); async function background() { const id = "single user script id"; function testRegister(props) { // ^ Not async, so that callers can test the difference between sync vs // async errors from userScripts.register(). return browser.userScripts.register([ { id, includeGlobs: ["*"], js: [{ code: "// ..." }], ...props }, ]); } async function doUnregister() { await browser.userScripts.unregister({ ids: [id] }); } try { await browser.test.assertRejects( testRegister({ worldId: "_" }), "Invalid worldId: _", "worldId starting with underscore are reserved" ); browser.test.log("worldId containing underscore after start is OK"); await testRegister({ worldId: "x_" }); await doUnregister(); await browser.test.assertRejects( testRegister({ worldId: "x".repeat(257) }), /^Invalid worldId: x{257}$/, "Too long worldId is rejected" ); browser.test.log("worldId length of 256 characters is OK"); await testRegister({ worldId: "x".repeat(256) }); await doUnregister(); browser.test.log("worldId length of 256 double-byte characters is OK"); await testRegister({ worldId: "\u{1234}".repeat(256) }); await doUnregister(); // The above shows that we do not count by the number of bytes, but by // the JS string length. The following assertion shows that we do not // somehow count by the number of Unicode characters. await browser.test.assertRejects( testRegister({ worldId: "\u{1f00d}".repeat(256) }), /^Invalid worldId: \u{1f00d}{256}$/u, "worldId length of 256 multi-code unit characters is rejected." ); browser.test.assertThrows( () => testRegister({ worldId: 123 }), /worldId: Expected string instead of 123/, "Non-string worldId is rejected." ); // Now test that worldId cannot be used with world "MAIN". await browser.test.assertRejects( testRegister({ world: "MAIN", worldId: "i" }), "worldId cannot be used with MAIN world.", "Should not support worldId with MAIN world" ); await browser.test.assertRejects( testRegister({ world: "MAIN", worldId: "i" }), "worldId cannot be used with MAIN world.", "Should not support worldId with MAIN world" ); // And not even with update(). await testRegister({ world: "MAIN" }); await browser.test.assertRejects( browser.userScripts.update([{ id, worldId: "y" }]), "worldId cannot be used with MAIN world.", "Should not update worldId to non-default world for world MAIN" ); browser.test.log("Updating worldId + world=USER_SCRIPT at once is OK"); await browser.userScripts.update([ { id, world: "USER_SCRIPT", worldId: "y" }, ]); await browser.test.assertRejects( browser.userScripts.update([{ id, world: "MAIN" }]), "worldId cannot be used with MAIN world.", "Should not change world to MAIN when worldId is non-default worldId" ); browser.test.log("Update can set world=MAIN and clear worldId at once"); await browser.userScripts.update([{ id, world: "MAIN", worldId: "" }]); await doUnregister(); } catch (e) { browser.test.fail(`Unexpected error: ${e}`); } browser.test.sendMessage("done"); } let extension = ExtensionTestUtils.loadExtension({ manifest: { browser_specific_settings: { gecko: { id: extensionId } }, manifest_version: 3, optional_permissions: ["userScripts"], }, background, }); await extension.startup(); await extension.awaitMessage("done"); await extension.unload(); }); add_task(async function test_default_and_many_non_default_worldIds() { const extensionId = "@test_default_and_many_non_default_worldIds"; await grantUserScriptsPermission(extensionId); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { browser_specific_settings: { gecko: { id: extensionId } }, manifest_version: 3, optional_permissions: ["userScripts"], host_permissions: ["*://example.com/*"], }, async background() { const matches = ["*://example.com/dummy"]; let scripts = [ { id: "define res, so other scripts can push results", matches, js: [{ code: `window.res = [];` }], runAt: "document_start", world: "MAIN", // worldId not specified - not meaningful for world "MAIN". }, { id: "default world (worldId not specified)", matches, js: [{ code: `var x = x ?? []; x.push(-3)` }], runAt: "document_start", world: "USER_SCRIPT", // worldId not specified = default world. }, { id: "default world (worldId is empty string)", matches, js: [{ code: `var x = x ?? []; x.push(-2)` }], runAt: "document_start", world: "USER_SCRIPT", worldId: "", // worldId "" is the default world. }, { id: "default world (worldId is null)", matches, js: [{ code: `var x = x ?? []; x.push(-1)` }], runAt: "document_start", world: "USER_SCRIPT", worldId: null, // worldId null defaults to default world. }, { id: "default world (export result from previous scripts)", matches, js: [{ code: `window.wrappedJSObject.res.push(...x)` }], runAt: "document_end", // Runs after document_start in default world. world: "USER_SCRIPT", // worldId not specified = default world. }, ]; // expected result is [-3, -2, -1] is from default world above. const expectedResults = [-3, -2, -1]; // plus 1...50 from loop below. for (let i = 1; i <= 50; ++i) { expectedResults.push(i); // The first script initializes "x" if not done so before, the second // script exports it to the main world. scripts.push({ id: `user script ${i} at document_start`, matches, js: [{ code: `var x = x ?? ${i};` }], runAt: "document_start", world: "USER_SCRIPT", worldId: `worldId ${i}`, }); scripts.push({ id: `user script ${i} at document_end`, matches, // If worlds were unexpectedly shared, x would be from another script // and false would be added to res instead of the number i. js: [{ code: `window.wrappedJSObject.res.push(x === ${i} && ${i})` }], runAt: "document_end", world: "USER_SCRIPT", worldId: `worldId ${i}`, }); } // If the page loads very fast, it is possible for document_end scripts // to still be compiling and not having been executed yet. To ensure that // we only check the results after all scripts have run, schedule a // document_idle script that runs after everything else. scripts.push({ id: "document_idle, runs after everything else", matches: ["*://example.com/dummy"], js: [{ code: `window.wrappedJSObject.allTestScriptsRan = true;` }], runAt: "document_idle", }); await browser.userScripts.register(scripts); browser.test.sendMessage("registered_and_expected", expectedResults); }, }); await extension.startup(); let expectedRes = await extension.awaitMessage("registered_and_expected"); const actualRes = await spawnPage(async () => { await ContentTaskUtils.waitForCondition( () => this.content.wrappedJSObject.allTestScriptsRan, "Waiting for all user scripts to have completed running" ); return this.content.wrappedJSObject.res; }); // Script execution order is guaranteed to be some defined order. It is // currently the order of registration, but may change, see bug 1963072. Assert.deepEqual( actualRes, expectedRes, "Every script should execute in the world specified by worldId" ); await extension.unload(); });