Bug 1437864 - Implement userScripts API methods to allow an extension to inject custom APIs in the isolated userScripts sandboxes. draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 14 Feb 2018 17:32:33 +0100
changeset 830168 9749cfba4410a88a5def590978beddb394fe7d76
parent 830115 1462e2ed11cb16935d9ce76c54dfe2d365bd30a8
push id118820
push userluca.greco@alcacoop.it
push dateMon, 20 Aug 2018 12:46:28 +0000
bugs1437864
milestone63.0a1
Bug 1437864 - Implement userScripts API methods to allow an extension to inject custom APIs in the isolated userScripts sandboxes. MozReview-Commit-ID: 3GIFhnxMJVn
browser/components/extensions/test/mochitest/test_ext_all_apis.html
mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/child/ext-toolkit.js
toolkit/components/extensions/child/ext-userScripts.js
toolkit/components/extensions/ext-toolkit.json
toolkit/components/extensions/schemas/user_scripts.json
toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -8,16 +8,17 @@
   <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
 </head>
 <body>
 <script>
 "use strict";
 /* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
 let expectedContentApisTargetSpecific = [
+  "userScripts.setScriptAPIs",
 ];
 
 let expectedBackgroundApisTargetSpecific = [
   "tabs.MutedInfoReason",
   "tabs.TAB_ID_NONE",
   "tabs.TabStatus",
   "tabs.UpdatePropertyName",
   "tabs.WindowType",
--- a/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -8,16 +8,17 @@
   <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
 </head>
 <body>
 <script>
 "use strict";
 /* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
 let expectedContentApisTargetSpecific = [
+  "userScripts.setScriptAPIs",
 ];
 
 let expectedBackgroundApisTargetSpecific = [
   "tabs.MutedInfoReason",
   "tabs.TAB_ID_NONE",
   "tabs.TabStatus",
   "tabs.WindowType",
   "tabs.ZoomSettingsMode",
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -1059,16 +1059,22 @@ class ChildAPIManager {
     }
 
     // Do not generate devtools APIs, unless explicitly allowed.
     if (this.context.envType !== "devtools_child" &&
         allowedContexts.includes("devtools_only")) {
       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;
   }
 
   getImplementation(namespace, name) {
     this.apiCan.findAPIPath(`${namespace}.${name}`);
     let obj = this.apiCan.findAPIPath(namespace);
 
     if (obj && name in obj) {
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -34,16 +34,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
 
 const {
   DefaultMap,
   DefaultWeakMap,
+  ExtensionError,
   getInnerWindowID,
   getWinUtils,
   promiseDocumentIdle,
   promiseDocumentLoaded,
   promiseDocumentReady,
 } = ExtensionUtils;
 
 const {
@@ -491,17 +492,16 @@ class Script {
     }
 
     await cssPromise;
     return result;
   }
 
   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
     // parsing and wait all promises to resolve.
     if (!scripts.every(script => script)) {
       let promise = Promise.all(scriptPromises);
 
       // If we're supposed to inject at the start of the document load,
@@ -525,52 +525,85 @@ class UserScript extends Script {
    * @param {BrowserExtensionContent} extension
    * @param {WebExtensionContentScript|object} matcher
    *        An object with a "matchesWindow" method and content script execution
    *        details.
    */
   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>
     this.sandboxes = new DefaultWeakMap((context) => {
       return this.createSandbox(context);
     });
   }
 
   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;
   }
 
   async inject(context) {
     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.
     TelemetryStopwatch.start(USER_SCRIPT_INJECTION_HISTOGRAM, context);
     try {
       let userScriptSandbox = this.sandboxes.get(context);
 
       context.callOnClose({
         close: () => {
           // Destroy the userScript sandbox when the related ContentScriptContextChild instance
           // is being closed.
           this.sandboxes.delete(context);
           Cu.nukeSandbox(userScriptSandbox);
         },
       });
 
+      // Inject the custom API registered by the extension API script.
+      if (apiScript) {
+        this.injectAPIs(userScriptSandbox, context);
+      }
+
       for (let script of sandboxScripts) {
         script.executeInGlobal(userScriptSandbox);
       }
     } finally {
       TelemetryStopwatch.finish(USER_SCRIPT_INJECTION_HISTOGRAM, context);
     }
   }
 
@@ -596,16 +629,80 @@ class UserScript extends Script {
       metadata: {
         "inner-window-id": context.innerWindowID,
         addonId: this.extension.policy.id,
       },
     });
 
     return sandbox;
   }
+
+  injectAPIs(sandbox, context) {
+    const {extension, scriptMetadata} = this;
+    const {userScriptAPIs, cloneScope} = context;
+
+    if (!userScriptAPIs) {
+      return;
+    }
+
+    const clonedMetadata = scriptMetadata ?
+            Cu.cloneInto(scriptMetadata, cloneScope) : undefined;
+    const SandboxError = sandbox.Error;
+
+    function safeReturnCloned(res) {
+      try {
+        return Cu.cloneInto(res, sandbox);
+      } catch (err) {
+        Cu.reportError(
+          `userScripts API method wrapper for ${extension.policy.debugName}: ${err}`
+        );
+        throw new SandboxError("Unable to clone object in the userScript sandbox");
+      }
+    }
+
+    function wrapUserScriptAPIMethod(fn) {
+      return Cu.exportFunction(function(...args) {
+        let fnArgs;
+
+        try {
+          fnArgs = Cu.cloneInto(args, cloneScope, {cloneFunctions: true});
+        } catch (err) {
+          Cu.reportError(`Error cloning userScriptAPIMethod parameters: ${err}`);
+          throw new SandboxError("Only serializable parameters are supported");
+        }
+
+        const res = runSafeSyncWithoutClone(fn, fnArgs, clonedMetadata, sandbox);
+
+        if (res instanceof cloneScope.Promise) {
+          return sandbox.Promise.resolve().then(async () => {
+            let value;
+            try {
+              value = await res;
+            } catch (err) {
+              if (err instanceof cloneScope.Error) {
+                throw new SandboxError(err.message);
+              } else {
+                throw safeReturnCloned(err);
+              }
+            }
+            return safeReturnCloned(value, sandbox);
+          });
+        }
+
+        return safeReturnCloned(res, sandbox);
+      }, sandbox);
+    }
+
+    for (let key of Object.keys(userScriptAPIs)) {
+      Schemas.exportLazyGetter(sandbox, key, () => {
+        // Wrap the custom API methods exported to the userScript sandbox.
+        return wrapUserScriptAPIMethod(userScriptAPIs[key]);
+      });
+    }
+  }
 }
 
 /**
  * An execution context for semi-privileged extension content scripts.
  *
  * This is the child side of the ContentScriptContextParent class
  * defined in ExtensionParent.jsm.
  */
@@ -700,16 +797,23 @@ class ContentScriptContextChild extends 
       let chromeObj = Cu.createObjectIn(this.sandbox);
 
       this.childManager.inject(chromeObj);
       return chromeObj;
     });
 
     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 of the API scripts already executed in this context.
+    //   WeakSet<PreloadedScript>
+    this.apiScripts = new WeakSet();
   }
 
   injectAPI() {
     if (!this.isExtensionPage) {
       throw new Error("Cannot inject extension API into non-extension window");
     }
 
     // This is an iframe with content script API enabled (See Bug 1214658)
@@ -718,16 +822,32 @@ class ContentScriptContextChild extends 
     Schemas.exportLazyGetter(this.contentWindow,
                              "chrome", () => this.chromeObj);
   }
 
   get cloneScope() {
     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 apiScripts only once.
+    if (apiScript && !this.apiScripts.has(apiScript)) {
+      apiScript.executeInGlobal(this.cloneScope);
+      this.apiScripts.add(apiScript);
+    }
+  }
+
   addScript(script) {
     if (script.requiresCleanup) {
       this.scripts.push(script);
     }
   }
 
   close() {
     super.unload();
--- a/toolkit/components/extensions/child/ext-toolkit.js
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -62,17 +62,17 @@ extensions.registerModules({
     url: "chrome://extensions/content/child/ext-test.js",
     scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"],
     paths: [
       ["test"],
     ],
   },
   userScripts: {
     url: "chrome://extensions/content/child/ext-userScripts.js",
-    scopes: ["addon_child"],
+    scopes: ["addon_child", "content_child"],
     paths: [
       ["userScripts"],
     ],
   },
   webRequest: {
     url: "chrome://extensions/content/child/ext-webRequest.js",
     scopes: ["addon_child"],
     paths: [
--- a/toolkit/components/extensions/child/ext-userScripts.js
+++ b/toolkit/components/extensions/child/ext-userScripts.js
@@ -145,12 +145,15 @@ this.userScripts = class extends Extensi
             }));
 
             const scriptId = await context.childManager.callParentAsyncFunction(
               "userScripts.register", [options]);
 
             return convertToAPIObject(scriptId, options);
           });
         },
+        setScriptAPIs(exportedAPIMethods) {
+          context.setUserScriptAPIs(exportedAPIMethods);
+        },
       },
     };
   }
 };
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -187,17 +187,17 @@
     "scopes": ["addon_parent"],
     "paths": [
       ["topSites"]
     ]
   },
   "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"]
     ]
   },
   "webNavigation": {
     "url": "chrome://extensions/content/parent/ext-webNavigation.js",
     "schema": "chrome://extensions/content/schemas/web_navigation.json",
     "scopes": ["addon_parent"],
--- a/toolkit/components/extensions/schemas/user_scripts.json
+++ b/toolkit/components/extensions/schemas/user_scripts.json
@@ -1,17 +1,43 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 [
   {
+    "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",
         "description": "Details of a user script",
         "properties": {
           "js": {
             "type": "array",
             "optional": true,
             "description": "The list of JS files to inject",
@@ -87,12 +113,24 @@
         "description": "Register a user script programmatically given its $(ref:userScripts.UserScriptOptions), and resolves to a $(ref:userScripts.RegisteredUserScript) instance",
         "async": true,
         "parameters": [
           {
             "name": "userScriptOptions",
             "$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"
+          }
+        ]
       }
     ]
   }
 ]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -196,8 +196,138 @@ add_task(async function test_userScripts
   Assert.deepEqual(reply2, {
     textContent: "userScript loaded - undefined",
     url: url2,
   }, "The userScript executed on the expected url and no access to the WebExtensions APIs");
   await contentPage2.close();
 
   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 asyncAPIResult = await US_async_api_with_callback("param3", (cbParam) => {
+        return `callback param: ${JSON.stringify(cbParam)}`;
+      });
+
+
+      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() {
+    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], scriptMetadata, scriptGlobal) {
+        browser.test.assertEq("function", typeof cb, "Got a callback 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();
+});