зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1437864 - Implement userScripts API methods to allow an extension to inject custom APIs in the isolated userScripts sandboxes. r=zombie,mixedpuppy
MozReview-Commit-ID: 3GIFhnxMJVn Depends on D4354 Differential Revision: https://phabricator.services.mozilla.com/D4355 --HG-- extra : moz-landing-system : lando
This commit is contained in:
Родитель
049cf75aa4
Коммит
fd88da28d9
|
@ -13,6 +13,7 @@
|
|||
"use strict";
|
||||
/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
|
||||
let expectedContentApisTargetSpecific = [
|
||||
"userScripts.setScriptAPIs",
|
||||
];
|
||||
|
||||
let expectedBackgroundApisTargetSpecific = [
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"use strict";
|
||||
/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
|
||||
let expectedContentApisTargetSpecific = [
|
||||
"userScripts.setScriptAPIs",
|
||||
];
|
||||
|
||||
let expectedBackgroundApisTargetSpecific = [
|
||||
|
|
|
@ -1072,6 +1072,12 @@ class ChildAPIManager {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Do not generate content_only APIs, unless explicitly allowed.
|
||||
if (this.context.envType !== "content_child" &&
|
||||
allowedContexts.includes("content_only")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
|
|||
const {
|
||||
DefaultMap,
|
||||
DefaultWeakMap,
|
||||
ExtensionError,
|
||||
getInnerWindowID,
|
||||
getWinUtils,
|
||||
promiseDocumentIdle,
|
||||
|
@ -498,7 +499,6 @@ class Script {
|
|||
|
||||
async awaitCompiledScripts(context) {
|
||||
let scriptPromises = this.compileScripts();
|
||||
|
||||
let scripts = scriptPromises.map(promise => promise.script);
|
||||
|
||||
// If not all scripts are already available in the cache, block
|
||||
|
@ -532,6 +532,13 @@ class UserScript extends Script {
|
|||
constructor(extension, matcher) {
|
||||
super(extension, matcher);
|
||||
|
||||
// This is an opaque object that the extension provides, it is associated to
|
||||
// the particular userScript and it is passed as a parameter to the custom
|
||||
// userScripts APIs defined by the extension.
|
||||
this.scriptMetadata = matcher.userScriptOptions.scriptMetadata;
|
||||
this.apiScriptURL = extension.manifest.userScripts && extension.manifest.userScripts.apiScript;
|
||||
|
||||
this.promiseAPIScript = null;
|
||||
this.scriptPromises = null;
|
||||
|
||||
// WeakMap<ContentScriptContextChild, Sandbox>
|
||||
|
@ -541,10 +548,18 @@ class UserScript extends Script {
|
|||
}
|
||||
|
||||
compileScripts() {
|
||||
if (this.apiScriptURL && !this.promiseAPIScript) {
|
||||
this.promiseAPIScript = this.scriptCache.get(this.apiScriptURL);
|
||||
}
|
||||
|
||||
if (!this.scriptPromises) {
|
||||
this.scriptPromises = this.js.map(url => this.scriptCache.get(url));
|
||||
}
|
||||
|
||||
if (this.promiseAPIScript) {
|
||||
return [this.promiseAPIScript, ...this.scriptPromises];
|
||||
}
|
||||
|
||||
return this.scriptPromises;
|
||||
}
|
||||
|
||||
|
@ -553,7 +568,20 @@ class UserScript extends Script {
|
|||
|
||||
DocumentManager.lazyInit();
|
||||
|
||||
let sandboxScripts = await this.awaitCompiledScripts(context);
|
||||
let scripts = await this.awaitCompiledScripts(context);
|
||||
|
||||
let apiScript, sandboxScripts;
|
||||
|
||||
if (this.promiseAPIScript) {
|
||||
[apiScript, ...sandboxScripts] = scripts;
|
||||
} else {
|
||||
sandboxScripts = scripts;
|
||||
}
|
||||
|
||||
// Load and execute the API script once per context.
|
||||
if (apiScript) {
|
||||
context.executeAPIScript(apiScript);
|
||||
}
|
||||
|
||||
// The evaluations below may throw, in which case the promise will be
|
||||
// automatically rejected.
|
||||
|
@ -570,6 +598,11 @@ class UserScript extends Script {
|
|||
},
|
||||
});
|
||||
|
||||
// Inject the custom API registered by the extension API script.
|
||||
if (apiScript) {
|
||||
this.injectUserScriptAPIs(userScriptSandbox, context);
|
||||
}
|
||||
|
||||
for (let script of sandboxScripts) {
|
||||
script.executeInGlobal(userScriptSandbox);
|
||||
}
|
||||
|
@ -605,6 +638,83 @@ class UserScript extends Script {
|
|||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
injectUserScriptAPIs(userScriptScope, context) {
|
||||
const {extension, scriptMetadata} = this;
|
||||
const {userScriptAPIs, cloneScope: apiScope} = context;
|
||||
|
||||
if (!userScriptAPIs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedMetadata = scriptMetadata ?
|
||||
Cu.cloneInto(scriptMetadata, apiScope) : undefined;
|
||||
|
||||
const UserScriptError = userScriptScope.Error;
|
||||
const UserScriptPromise = userScriptScope.Promise;
|
||||
|
||||
const wrappedFnMap = new WeakMap();
|
||||
|
||||
function safeReturnCloned(res) {
|
||||
try {
|
||||
return Cu.cloneInto(res, userScriptScope);
|
||||
} catch (err) {
|
||||
Cu.reportError(
|
||||
`userScripts API method wrapper for ${extension.policy.debugName}: ${err}`
|
||||
);
|
||||
throw new UserScriptError("Unable to clone object in the userScript sandbox");
|
||||
}
|
||||
}
|
||||
|
||||
function wrapUserScriptAPIMethod(fn, fnName) {
|
||||
return Cu.exportFunction(function(...args) {
|
||||
let fnArgs = Cu.cloneInto([], apiScope);
|
||||
|
||||
try {
|
||||
for (let arg of args) {
|
||||
if (typeof arg === "function") {
|
||||
if (!wrappedFnMap.has(arg)) {
|
||||
wrappedFnMap.set(arg, Cu.exportFunction(arg, apiScope));
|
||||
}
|
||||
fnArgs.push(wrappedFnMap.get(arg));
|
||||
} else {
|
||||
fnArgs.push(Cu.cloneInto(arg, apiScope));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Cu.reportError(`Error cloning userScriptAPIMethod parameters in ${fnName}: ${err}`);
|
||||
throw new UserScriptError("Only serializable parameters are supported");
|
||||
}
|
||||
|
||||
const res = runSafeSyncWithoutClone(fn, fnArgs, clonedMetadata, userScriptScope);
|
||||
|
||||
if (res instanceof context.Promise) {
|
||||
return UserScriptPromise.resolve().then(async () => {
|
||||
let value;
|
||||
try {
|
||||
value = await res;
|
||||
} catch (err) {
|
||||
if (err instanceof context.Error) {
|
||||
throw new UserScriptError(err.message);
|
||||
} else {
|
||||
throw safeReturnCloned(err);
|
||||
}
|
||||
}
|
||||
return safeReturnCloned(value);
|
||||
});
|
||||
}
|
||||
|
||||
return safeReturnCloned(res);
|
||||
}, userScriptScope);
|
||||
}
|
||||
|
||||
for (let key of Object.keys(userScriptAPIs)) {
|
||||
Schemas.exportLazyGetter(userScriptScope, key, () => {
|
||||
// Wrap the custom API methods exported to the userScript sandbox.
|
||||
return wrapUserScriptAPIMethod(userScriptAPIs[key], key);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentScripts = new DefaultWeakMap(matcher => {
|
||||
|
@ -725,6 +835,14 @@ class ContentScriptContextChild extends BaseContext {
|
|||
|
||||
Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
|
||||
Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
|
||||
|
||||
// A set of exported API methods provided by the extension to the userScripts sandboxes.
|
||||
this.userScriptAPIs = null;
|
||||
|
||||
// Keep track if the userScript API script has been already executed in this context
|
||||
// (e.g. because there are more then one UserScripts that match the related webpage
|
||||
// and so the UserScript apiScript has already been executed).
|
||||
this.hasUserScriptAPIs = false;
|
||||
}
|
||||
|
||||
injectAPI() {
|
||||
|
@ -743,6 +861,23 @@ class ContentScriptContextChild extends BaseContext {
|
|||
return this.sandbox;
|
||||
}
|
||||
|
||||
setUserScriptAPIs(extCustomAPIs) {
|
||||
if (this.userScriptAPIs) {
|
||||
throw new ExtensionError("userScripts APIs may only be set once");
|
||||
}
|
||||
|
||||
this.userScriptAPIs = extCustomAPIs;
|
||||
}
|
||||
|
||||
async executeAPIScript(apiScript) {
|
||||
// Execute the UserScript apiScript only once per context (e.g. more then one UserScripts
|
||||
// match the same webpage and the apiScript has already been executed).
|
||||
if (apiScript && !this.hasUserScriptAPIs) {
|
||||
this.hasUserScriptAPIs = true;
|
||||
apiScript.executeInGlobal(this.cloneScope);
|
||||
}
|
||||
}
|
||||
|
||||
addScript(script) {
|
||||
if (script.requiresCleanup) {
|
||||
this.scripts.push(script);
|
||||
|
|
|
@ -67,7 +67,7 @@ extensions.registerModules({
|
|||
},
|
||||
userScripts: {
|
||||
url: "chrome://extensions/content/child/ext-userScripts.js",
|
||||
scopes: ["addon_child"],
|
||||
scopes: ["addon_child", "content_child"],
|
||||
paths: [
|
||||
["userScripts"],
|
||||
],
|
||||
|
|
|
@ -150,6 +150,9 @@ this.userScripts = class extends ExtensionAPI {
|
|||
return convertToAPIObject(scriptId, options);
|
||||
});
|
||||
},
|
||||
setScriptAPIs(exportedAPIMethods) {
|
||||
context.setUserScriptAPIs(exportedAPIMethods);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -192,7 +192,7 @@
|
|||
"userScripts": {
|
||||
"url": "chrome://extensions/content/parent/ext-userScripts.js",
|
||||
"schema": "chrome://extensions/content/schemas/user_scripts.json",
|
||||
"scopes": ["addon_parent"],
|
||||
"scopes": ["addon_parent", "content_parent"],
|
||||
"paths": [
|
||||
["userScripts"]
|
||||
]
|
||||
|
|
|
@ -4,8 +4,32 @@
|
|||
|
||||
[
|
||||
{
|
||||
"namespace": "userScripts",
|
||||
"namespace": "manifest",
|
||||
"types": [
|
||||
{
|
||||
"$extend": "WebExtensionManifest",
|
||||
"properties": {
|
||||
"userScripts": {
|
||||
"type": "object",
|
||||
"optional": true,
|
||||
"properties": {
|
||||
"apiScript": { "$ref": "manifest.ExtensionURL"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "userScripts",
|
||||
"allowedContexts": ["content"],
|
||||
"types": [
|
||||
{
|
||||
"id": "ExportedAPIMethods",
|
||||
"type": "object",
|
||||
"description": "A set of API methods provided by the extensions to its userScripts",
|
||||
"additionalProperties": { "type": "function" }
|
||||
},
|
||||
{
|
||||
"id": "UserScriptOptions",
|
||||
"type": "object",
|
||||
|
@ -92,6 +116,18 @@
|
|||
"$ref": "UserScriptOptions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setScriptAPIs",
|
||||
"allowedContexts": ["content", "content_only"],
|
||||
"type": "function",
|
||||
"description": "Provides a set of custom API methods available to the registered userScripts",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "exportedAPIMethods",
|
||||
"$ref": "ExportedAPIMethods"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -205,3 +205,140 @@ add_task(async function test_userScripts_no_webext_apis() {
|
|||
|
||||
await extension.unload();
|
||||
});
|
||||
|
||||
add_task(async function test_userScripts_exported_APIs() {
|
||||
async function background() {
|
||||
const matches = ["http://localhost/*/file_sample.html"];
|
||||
|
||||
await browser.runtime.onMessage.addListener(async (msg, sender) => {
|
||||
return {bgPageReply: true};
|
||||
});
|
||||
|
||||
async function userScript() {
|
||||
// Explicitly retrieve the custom exported API methods
|
||||
// to prevent eslint to raise a no-undef validation
|
||||
// error for them.
|
||||
const {
|
||||
US_sync_api,
|
||||
US_async_api_with_callback,
|
||||
US_send_api_results,
|
||||
} = this;
|
||||
this.userScriptGlobalVar = "global-sandbox-value";
|
||||
|
||||
const syncAPIResult = US_sync_api("param1", "param2");
|
||||
const cb = (cbParam) => {
|
||||
return `callback param: ${JSON.stringify(cbParam)}`;
|
||||
};
|
||||
const cb2 = cb;
|
||||
const asyncAPIResult = await US_async_api_with_callback("param3", cb, cb2);
|
||||
|
||||
let expectedError;
|
||||
|
||||
// This is expect to raise an exception due to the window parameter which can't
|
||||
// be cloned.
|
||||
try {
|
||||
US_sync_api(window);
|
||||
} catch (err) {
|
||||
expectedError = err.message;
|
||||
}
|
||||
|
||||
US_send_api_results({syncAPIResult, asyncAPIResult, expectedError});
|
||||
}
|
||||
|
||||
await browser.userScripts.register({
|
||||
js: [{
|
||||
code: `(${userScript})();`,
|
||||
}],
|
||||
runAt: "document_end",
|
||||
matches,
|
||||
scriptMetadata: {
|
||||
name: "test-user-script-exported-apis",
|
||||
},
|
||||
});
|
||||
|
||||
browser.test.sendMessage("background-ready");
|
||||
}
|
||||
|
||||
function apiScript() {
|
||||
// Redefine Promise and Error globals to verify that it doesn't break the WebExtensions internals
|
||||
// that are going to use them.
|
||||
this.Promise = {};
|
||||
this.Error = {};
|
||||
|
||||
browser.userScripts.setScriptAPIs({
|
||||
US_sync_api([param1, param2], scriptMetadata, scriptGlobal) {
|
||||
browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name);
|
||||
|
||||
browser.test.assertEq("param1", param1, "Got the expected parameter value");
|
||||
browser.test.assertEq("param2", param2, "Got the expected parameter value");
|
||||
|
||||
browser.test.sendMessage("US_sync_api", {param1, param2});
|
||||
|
||||
return "returned_value";
|
||||
},
|
||||
async US_async_api_with_callback([param, cb, cb2], scriptMetadata, scriptGlobal) {
|
||||
browser.test.assertEq("function", typeof cb, "Got a callback function parameter");
|
||||
browser.test.assertTrue(cb === cb2, "Got the same cloned function for the same function parameter");
|
||||
|
||||
browser.runtime.sendMessage({param}).then(bgPageRes => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const cbResult = cb(cloneInto(bgPageRes, scriptGlobal));
|
||||
browser.test.sendMessage("US_async_api_with_callback", cbResult);
|
||||
});
|
||||
|
||||
return "resolved_value";
|
||||
},
|
||||
async US_send_api_results([results], scriptMetadata, scriptGlobal) {
|
||||
browser.test.sendMessage("US_send_api_results", results);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let extensionData = {
|
||||
manifest: {
|
||||
permissions: [
|
||||
"http://localhost/*/file_sample.html",
|
||||
],
|
||||
userScripts: {
|
||||
apiScript: "api-script.js",
|
||||
},
|
||||
},
|
||||
background,
|
||||
files: {
|
||||
"api-script.js": apiScript,
|
||||
},
|
||||
};
|
||||
|
||||
let extension = ExtensionTestUtils.loadExtension(extensionData);
|
||||
|
||||
// Ensure that a content page running in a content process and which has been
|
||||
// already loaded when the content scripts has been registered, it has received
|
||||
// and registered the expected content scripts.
|
||||
let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
|
||||
|
||||
await extension.startup();
|
||||
|
||||
await extension.awaitMessage("background-ready");
|
||||
|
||||
await contentPage.loadURL(`${BASE_URL}/file_sample.html`);
|
||||
|
||||
info("Wait the userScript to call the exported US_sync_api method");
|
||||
await extension.awaitMessage("US_sync_api");
|
||||
|
||||
info("Wait the userScript to call the exported US_async_api_with_callback method");
|
||||
const userScriptCallbackResult = await extension.awaitMessage("US_async_api_with_callback");
|
||||
equal(userScriptCallbackResult, `callback param: {"bgPageReply":true}`,
|
||||
"Got the expected results when the userScript callback has been called");
|
||||
|
||||
info("Wait the userScript to call the exported US_send_api_results method");
|
||||
const userScriptsAPIResults = await extension.awaitMessage("US_send_api_results");
|
||||
Assert.deepEqual(userScriptsAPIResults, {
|
||||
syncAPIResult: "returned_value",
|
||||
asyncAPIResult: "resolved_value",
|
||||
expectedError: "Only serializable parameters are supported",
|
||||
}, "Got the expected userScript API results");
|
||||
|
||||
await extension.unload();
|
||||
|
||||
await contentPage.close();
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче