Bug 1911836 - Implement onUserScriptMessage r=zombie

Differential Revision: https://phabricator.services.mozilla.com/D229386
This commit is contained in:
Rob Wu 2024-11-21 21:35:07 +00:00
parent d37e90a463
commit d99a5fd35f
9 changed files with 577 additions and 5 deletions

View File

@ -330,6 +330,14 @@ export class Messenger {
this.onMessageEx = new MessageEvent(context, "runtime.onMessageExternal");
}
get onUserScriptMessage() {
return redefineGetter(
this,
"onUserScriptMessage",
new MessageEvent(this.context, "runtime.onUserScriptMessage")
);
}
sendNativeMessage(nativeApp, json) {
let holder = holdMessage(
`Messenger/${this.context.extension.id}/sendNativeMessage/${nativeApp}`,
@ -340,7 +348,11 @@ export class Messenger {
return this.conduit.queryNativeMessage({ nativeApp, holder });
}
sendRuntimeMessage({ extensionId, message, callback, ...args }) {
sendRuntimeMessage({ context, extensionId, message, callback, ...args }) {
// this.context is usually used, except with user scripts, where we pass a
// custom context to ensure that the return value is cloned into the right
// USER_SCRIPT world.
context ??= this.context;
let response = this.conduit.queryRuntimeMessage({
extensionId: extensionId || this.context.extension.id,
holder: holdMessage(
@ -352,7 +364,7 @@ export class Messenger {
});
// If |response| is a rejected promise, the value will be sanitized by
// wrapPromise, according to the rules of context.normalizeError.
return this.context.wrapPromise(response, callback);
return context.wrapPromise(response, callback);
}
connect({ name, native, ...args }) {
@ -372,8 +384,12 @@ export class Messenger {
}
}
recvRuntimeMessage({ extensionId, holder, sender }) {
recvRuntimeMessage({ extensionId, holder, sender, userScriptWorldId }) {
let event = sender.id === extensionId ? this.onMessage : this.onMessageEx;
if (typeof userScriptWorldId == "string") {
sender = { ...sender, userScriptWorldId };
return this.onUserScriptMessage.emit(holder, sender);
}
return event.emit(holder, sender);
}
}

View File

@ -11,8 +11,17 @@
*/
import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
const { DefaultMap, DefaultWeakMap } = ExtensionUtils;
/** @type {Lazy} */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
Schemas: "resource://gre/modules/Schemas.sys.mjs",
});
const { DefaultMap, DefaultWeakMap, ExtensionError } = ExtensionUtils;
const { BaseContext, redefineGetter } = ExtensionCommon;
class WorldConfigHolder {
/** @type {Map<ExtensionChild,WorldConfigHolder>} */
@ -38,6 +47,130 @@ class WorldConfigHolder {
this.defaultCSP
);
}
isMessagingEnabledForWorldId(worldId) {
return (
this.configs.get(worldId)?.messaging ??
this.configs.get("")?.messaging ??
false
);
}
}
/**
* A light wrapper around a ContentScriptContextChild to serve as a BaseContext
* instance to support APIs exposed to USER_SCRIPT worlds. Such contexts are
* usually heavier due to the need to track the document lifetime, but because
* all user script worlds and a content script for a document (and extension)
* shares the same lifetime, we delegate to the only ContentScriptContextChild
* that exists for the document+extension.
*/
class UserScriptContext extends BaseContext {
/**
* @param {ContentScriptContextChild} contentContext
* @param {Sandbox} sandbox
* @param {string} worldId
* @param {boolean} messaging
*/
constructor(contentContext, sandbox, worldId, messaging) {
// Note: envType "userscript_child" is currently not recognized elsewhere.
// In particular ParentAPIManager.recvCreateProxyContext refuses to create
// ProxyContextParent instances, which is desirable because an extension
// can create many user script worlds, and we do not want the overhead of
// a new ProxyContextParent for each USER_SCRIPT worldId.
super("userscript_child", contentContext.extension);
this.contentContext = contentContext;
this.#forwardGetterToOwnerContext("active");
this.#forwardGetterToOwnerContext("incognito");
this.#forwardGetterToOwnerContext("messageManager");
this.#forwardGetterToOwnerContext("contentWindow");
this.#forwardGetterToOwnerContext("innerWindowID");
this.cloneScopeError = sandbox.Error;
this.cloneScopePromise = sandbox.Promise;
this.sandbox = sandbox;
Object.defineProperty(this, "principal", {
value: Cu.getObjectPrincipal(sandbox),
enumerable: true,
configurable: true,
});
this.worldId = worldId;
this.enableMessaging = messaging;
contentContext.callOnClose(this);
}
close() {
super.close();
this.contentContext = null;
this.sandbox = null;
}
get cloneScope() {
return this.sandbox;
}
#forwardGetterToOwnerContext(name) {
Object.defineProperty(this, name, {
configurable: true,
enumerable: true,
get() {
return this.contentContext[name];
},
});
}
get browserObj() {
const browser = {};
// The set of APIs exposed to user scripts is minimal. For simplicity and
// minimizing overhead, we do not use Schemas-generated bindings.
const wrapF = func => {
return (...args) => {
try {
return func.apply(this, args);
} catch (e) {
throw this.normalizeError(e);
}
};
};
if (this.enableMessaging) {
browser.runtime = {};
browser.runtime.sendMessage = wrapF(this.runtimeSendMessage);
}
const value = Cu.cloneInto(browser, this.sandbox, { cloneFunctions: true });
return redefineGetter(this, "browserObj", value);
}
runtimeSendMessage(...args) {
// Simplified version of parseBonkersArgs in child/ext-runtime.js
let callback = typeof args[args.length - 1] === "function" && args.pop();
// The extensionId and options parameters are an optional part of the
// runtime.sendMessage() interface, but not supported in user scripts:
// runtime.sendMessage() will only trigger runtime.onUserScriptMessage and
// never runtime.onMessage nor runtime.onMessageExternal.
if (!args.length) {
throw new ExtensionError(
"runtime.sendMessage's message argument is missing"
);
} else if (args.length > 1) {
throw new ExtensionError(
"runtime.sendMessage received too many arguments"
);
}
let [message] = args;
return this.contentContext.messenger.sendRuntimeMessage({
context: this,
userScriptWorldId: this.worldId,
message,
callback,
});
}
}
class WorldCollection {
@ -103,7 +236,19 @@ class WorldCollection {
originAttributes: docPrincipal.originAttributes,
});
// TODO bug 1911836: Expose APIs when messaging is true.
let messaging = this.configHolder.isMessagingEnabledForWorldId(worldId);
if (messaging) {
let userScriptContext = new UserScriptContext(
this.context,
sandbox,
worldId,
messaging
);
const getBrowserObj = () => userScriptContext.browserObj;
lazy.Schemas.exportLazyGetter(sandbox, "browser", getBrowserObj);
lazy.Schemas.exportLazyGetter(sandbox, "chrome", getBrowserObj);
}
return sandbox;
}

View File

@ -63,6 +63,14 @@ this.runtime = class extends ExtensionAPI {
onConnectExternal: context.messenger.onConnectEx.api(),
onMessageExternal: context.messenger.onMessageEx.api(),
get onUserScriptMessage() {
return ExtensionCommon.redefineGetter(
this,
"onUserScriptMessage",
context.messenger.onUserScriptMessage.api()
);
},
connect(extensionId, options) {
let name = options?.name ?? "";
return context.messenger.connect({ name, extensionId });

View File

@ -35,6 +35,7 @@ this.runtime = class extends ExtensionAPIPersistent {
// - runtime.onConnectExternal
// - runtime.onMessage
// - runtime.onMessageExternal
// - runtime.onUserScriptMessage
// For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js.
onInstalled({ fire }) {

View File

@ -170,6 +170,11 @@
"type": "string",
"optional": true,
"description": "The TLS channel ID of the page or frame that opened the connection, if requested by the extension or app, and if available."
},
"userScriptWorldId": {
"type": "string",
"optional": true,
"description": "The worldId of the USER_SCRIPT world that sent the message. Only present on onUserScriptMessage events."
}
}
},
@ -800,6 +805,31 @@
"description": "Return true from the event listener if you wish to call <code>sendResponse</code> after the event listener returns."
}
},
{
"name": "onUserScriptMessage",
"type": "function",
"description": "Fired when a message is sent from a USER_SCRIPT world registered through the userScripts API.",
"permissions": ["userScripts"],
"parameters": [
{
"name": "message",
"type": "any",
"optional": true,
"description": "The message sent by the calling script."
},
{ "name": "sender", "$ref": "MessageSender" },
{
"name": "sendResponse",
"type": "function",
"description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one <code>onMessage</code> listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until <code>sendResponse</code> is called)."
}
],
"returns": {
"type": "boolean",
"optional": true,
"description": "Return true from the event listener if you wish to call <code>sendResponse</code> after the event listener returns."
}
},
{
"name": "onRestartRequired",
"unsupported": true,

View File

@ -230,6 +230,12 @@
"type": "string",
"optional": true,
"description": "The world's Content Security Policy. Defaults to the CSP of regular content scripts, which prohibits dynamic code execution such as eval."
},
"messaging": {
"type": "boolean",
"optional": true,
"default": false,
"description": "Whether the runtime.sendMessage and runtime.connect methods are exposed. Defaults to not exposing these messaging APIs."
}
}
}

View File

@ -69,6 +69,11 @@ async function startEvalTesterExtension() {
await browser.userScripts.resetWorldConfiguration(args);
} else if (msg === "getWorldConfigurations") {
let res = await browser.userScripts.getWorldConfigurations();
for (let properties of res) {
// We are only interested in the worldId / csp properties, so drop
// all other properties so we can keep the assertions simple.
delete properties.messaging;
}
browser.test.sendMessage("getWorldConfigurations:done", res);
return;
} else {

View File

@ -0,0 +1,359 @@
"use strict";
const { ExtensionTestCommon } = ChromeUtils.importESModule(
"resource://testing-common/ExtensionTestCommon.sys.mjs"
);
const { ExtensionUserScripts } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionUserScripts.sys.mjs"
);
const server = createHttpServer({ hosts: ["example.com", "example.net"] });
server.registerPathHandler("/dummy", () => {});
AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();
add_setup(async () => {
Services.prefs.setBoolPref("extensions.userScripts.mv3.enabled", true);
await ExtensionTestUtils.startAddonManager();
});
add_task(async function test_runtime_messaging_errors() {
let extension = ExtensionTestUtils.loadExtension({
useAddonManager: "permanent",
manifest: {
manifest_version: 3,
permissions: ["userScripts"],
host_permissions: ["*://example.com/*"],
content_scripts: [
{
js: ["contentscript_expose_test.js"],
matches: ["*://example.com/dummy"],
run_at: "document_start",
},
],
},
files: {
"contentscript_expose_test.js": () => {
// userscript.js does not have browser.test, export it from here.
// eslint-disable-next-line no-undef
window.wrappedJSObject.contentscriptTest = cloneInto(
browser.test,
window,
{ cloneFunctions: true }
);
},
"userscript.js": async () => {
// Note: this is the browser.test namespace from the content script.
// We can only pass primitive values here, because Xrays prevent the
// content script from receiving functions, etc.
const contentscriptTest = window.wrappedJSObject.contentscriptTest;
function assertThrows(cb, expectedError, desc) {
let actualErrorMessage;
try {
cb();
actualErrorMessage = "Unexpectedly not thrown";
} catch (e) {
actualErrorMessage = e.message;
}
contentscriptTest.assertEq(expectedError, actualErrorMessage, desc);
}
async function assertRejects(promise, expectedError, desc) {
let actualErrorMessage;
try {
await promise;
actualErrorMessage = "Unexpectedly not rejected";
} catch (e) {
actualErrorMessage = e.message;
}
contentscriptTest.assertEq(expectedError, actualErrorMessage, desc);
}
try {
assertThrows(
() => browser.runtime.sendMessage(),
"runtime.sendMessage's message argument is missing",
"sendMessage without params"
);
assertThrows(
() => browser.runtime.sendMessage("extensionId@", "message"),
"runtime.sendMessage received too many arguments",
"sendMessage with unsupported extensionId parameter"
);
assertThrows(
() => browser.runtime.sendMessage("message", {}),
"runtime.sendMessage received too many arguments",
"sendMessage with unsupported options parameter"
);
assertThrows(
() => browser.runtime.sendMessage(location),
"Location object could not be cloned.",
"sendMessage with non-cloneable message"
);
await assertRejects(
browser.runtime.sendMessage("msg"),
"Could not establish connection. Receiving end does not exist.",
"Expected error when there is no onUserScriptMessage handler"
);
} catch (e) {
contentscriptTest.fail(`Unexpected error in userscript.js: ${e}`);
}
contentscriptTest.sendMessage("done");
},
},
async background() {
await browser.userScripts.configureWorld({ messaging: true });
await browser.userScripts.register([
{
id: "error checker",
matches: ["*://example.com/dummy"],
js: [{ file: "userscript.js" }],
},
]);
browser.test.sendMessage("registered");
},
});
await extension.startup();
await extension.awaitMessage("registered");
let contentPage = await ExtensionTestUtils.loadContentPage(
"http://example.com/dummy"
);
await extension.awaitMessage("done");
await contentPage.close();
await extension.unload();
});
// This tests that runtime.sendMessage works when called from a user script.
// And that the messaging flag persists across restarts.
// Moreover, that runtime.sendMessage can wake up an event page.
add_task(async function test_onUserScriptMessage() {
let extension = ExtensionTestUtils.loadExtension({
useAddonManager: "permanent",
manifest: {
manifest_version: 3,
permissions: ["userScripts"],
host_permissions: ["*://example.com/*"],
},
files: {
"userscript.js": async () => {
// browser.runtime.sendMessage should be available because we call
// userScripts.configureWorld({ messaging: true }) before page load.
let responses = [
await browser.runtime.sendMessage("expectPromiseResult"),
await browser.runtime.sendMessage("expectSendResponse"),
await browser.runtime.sendMessage("expectSendResponseAsync"),
await browser.runtime.sendMessage("expectDefaultResponse"),
];
browser.runtime.sendMessage(responses);
},
},
background() {
// Set up message listeners. The user script will send multiple messages,
// and ultimately send a message will all responses received so far.
// NOTE: To make sure that the functionality is independent of other
// messaging APIs, we only register runtime.onUserScriptMessage here,
// and no other extension messaging APIs.
browser.runtime.onUserScriptMessage.addListener(
(msg, sender, sendResponse) => {
// worldId defaults to "" when not specified in userScripts.register
// and userScripts.configureWorld. That default value should appear
// here as sender.userScriptWorldId (also an empty string).
browser.test.assertEq(
"",
sender.userScriptWorldId,
`Expected userScriptWorldId in onUserScriptMessage for: ${msg}`
);
switch (msg) {
case "expectPromiseResult":
return Promise.resolve("Promise");
case "expectSendResponse":
sendResponse("sendResponse");
return;
case "expectSendResponseAsync":
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
setTimeout(() => sendResponse("sendResponseAsync"), 50);
return true;
case "expectDefaultResponse":
return;
default:
browser.test.assertDeepEq(
["Promise", "sendResponse", "sendResponseAsync", undefined],
msg,
"All sendMessage calls got the expected response"
);
browser.test.sendMessage("testRuntimeSendMessage:done");
}
}
);
browser.runtime.onInstalled.addListener(async () => {
await browser.userScripts.configureWorld({ messaging: true });
await browser.userScripts.register([
{
id: "messaging checker",
matches: ["*://example.com/dummy"],
js: [{ file: "userscript.js" }],
},
]);
browser.test.sendMessage("registered");
});
},
});
await extension.startup();
await extension.awaitMessage("registered");
async function testRuntimeSendMessage() {
let contentPage = await ExtensionTestUtils.loadContentPage(
"http://example.com/dummy"
);
await extension.awaitMessage("testRuntimeSendMessage:done");
await contentPage.close();
}
info("Loading page that should trigger runtime.sendMessage");
await testRuntimeSendMessage();
await AddonTestUtils.promiseShutdownManager();
ExtensionUserScripts._getStoreForTesting()._uninitForTesting();
await AddonTestUtils.promiseStartupManager();
// Because the background has a persistent listener (runtime.onInstalled), it
// stays suspended after a restart.
await extension.awaitStartup();
ExtensionTestCommon.testAssertions.assertBackgroundStatusStopped(extension);
// We expect that the load of the page that calls runtime.sendMessage from a
// user script will wake it to fire runtime.onUserScriptMessage.
info("Loading page that should load user script and wake event page");
await testRuntimeSendMessage();
await extension.unload();
});
// This test tests the following:
// - configureWorld() with messaging=false does not affect existing worlds.
// - after reloading the page, that the user script is run again but without
// access to messaging APIs due to messaging=false.
// - Moreover, this also verifies that runtime.sendMessage from a user script
// does not trigger runtime.onMessage / runtime.onMessageExternal, and that
// even if runtime.onMessage is triggered from a content script, that it does
// not have the userScripts-specific "sender.userScriptWorldId" field.
add_task(async function test_configureWorld_messaging_existing_world() {
let extension = ExtensionTestUtils.loadExtension({
useAddonManager: "permanent",
manifest: {
manifest_version: 3,
permissions: ["userScripts"],
host_permissions: ["*://example.com/*"],
content_scripts: [
{
js: ["contentscript_result_reporter.js"],
matches: ["*://example.com/dummy"],
run_at: "document_start",
},
],
},
files: {
"contentscript_result_reporter.js": () => {
// exportFunction is defined in the scope of a content script.
// eslint-disable-next-line no-undef
window.wrappedJSObject.reportResultViaContentScript = exportFunction(
msg => browser.runtime.sendMessage(msg),
window
);
},
"userscript.js": async () => {
try {
dump("Trying to call sendMessage(initial_message)\n");
await browser.runtime.sendMessage("initial_message");
dump("Trying to call sendMessage(after_messaging_false)\n");
await browser.runtime.sendMessage("after_messaging_false");
// ^ we expect runtime.sendMessage() to succeed despite configuring
// messaging to false, because we only check the flag when the APIs
// are initialized in the sandbox. This is consistent with Chrome's
// behavior. Note that the specification permits this behavior (but
// it also allows the implementation to fail if desired):
// https://github.com/w3c/webextensions/blob/d16807376b/proposals/multiple_user_script_worlds.md#L191-L193
dump("Reloading page\n");
location.reload();
// ^ after reloading the page, we expect the messaging=false flag to
// be enforced, and the first runtime.sendMessage() call should fail
// and fall through to the catch below.
} catch (e) {
window.wrappedJSObject.reportResultViaContentScript(`Result:${e}`);
}
},
},
async background() {
let msgCount = 0;
browser.runtime.onUserScriptMessage.addListener(async (msg, sender) => {
++msgCount;
browser.test.assertEq(
"non_default_worldId",
sender.userScriptWorldId,
"Expected userScriptWorldId in onUserScriptMessage"
);
if (msgCount === 1) {
browser.test.assertEq("initial_message", msg, "Initial message");
browser.test.log("Calling configureWorld with messaging=false");
await browser.userScripts.configureWorld({
worldId: "non_default_worldId",
messaging: false,
});
return;
}
if (msgCount === 2) {
browser.test.assertEq("after_messaging_false", msg, "Second message");
return;
}
// After reload.
browser.test.fail(`Unexpected onUserScriptMessage ${msgCount}: ${msg}`);
});
browser.runtime.onMessage.addListener((msg, sender) => {
browser.test.assertFalse(
"userScriptWorldId" in sender,
"No userScriptWorldId in runtime.onMessage"
);
browser.test.assertEq(
2,
msgCount,
"Should reach reportResultViaContentScript after reloading page"
);
browser.test.assertEq(
"Result:ReferenceError: browser is not defined",
msg,
"Expected (error) message after reload when messaging=false"
);
browser.test.sendMessage("done");
});
browser.runtime.onMessageExternal.addListener(msg => {
browser.test.fail(`Unexpected message: ${msg}`);
});
await browser.userScripts.configureWorld({ messaging: true });
await browser.userScripts.register([
{
id: "Test effect of configureWorld(messaging=false) and reload",
matches: ["*://example.com/dummy"],
js: [{ file: "userscript.js" }],
worldId: "non_default_worldId",
},
]);
browser.test.sendMessage("registered");
},
});
await extension.startup();
await extension.awaitMessage("registered");
let contentPage = await ExtensionTestUtils.loadContentPage(
"http://example.com/dummy"
);
await extension.awaitMessage("done");
await contentPage.close();
await extension.unload();
});

View File

@ -621,6 +621,8 @@ run-sequentially = "very high failure rate in parallel"
["test_ext_userScripts_mv3_injection.js"]
["test_ext_userScripts_mv3_messaging.js"]
["test_ext_userScripts_mv3_persistence.js"]
["test_ext_userScripts_mv3_worlds.js"]