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:
Luca Greco 2018-09-11 10:09:50 +00:00
Родитель 049cf75aa4
Коммит fd88da28d9
9 изменённых файлов: 324 добавлений и 5 удалений

Просмотреть файл

@ -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();
});