Bug 1437861 - Implement userScripts.register and execute userScripts js code in isolated sandboxes. draft
authorLuca Greco <lgreco@mozilla.com>
Mon, 12 Feb 2018 02:59:04 +0100
changeset 830115 1462e2ed11cb16935d9ce76c54dfe2d365bd30a8
parent 829705 cfbaf72d15f0696d00285872813c781557c3f59d
child 830168 9749cfba4410a88a5def590978beddb394fe7d76
child 830254 e6a3fa35c098cf0e89337d3480576a63535aa96a
child 830258 00ccd3de6c194ab70261ecfe1b7475b05a888da3
push id118815
push userluca.greco@alcacoop.it
push dateSun, 19 Aug 2018 16:12:45 +0000
bugs1437861
milestone63.0a1
Bug 1437861 - Implement userScripts.register and execute userScripts js code in isolated sandboxes. MozReview-Commit-ID: 6RamPDLNK4V
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/extension-process-script.js
toolkit/components/extensions/jar.mn
toolkit/components/extensions/parent/ext-userScripts.js
toolkit/components/extensions/schemas/content_scripts.json
toolkit/components/extensions/schemas/extension_types.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/user_scripts.json
toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
toolkit/components/extensions/test/xpcshell/head_handling_user_input.js
toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -62,16 +62,18 @@ const {
 
 XPCOMUtils.defineLazyGetter(this, "console", ExtensionCommon.getConsole);
 
 
 var DocumentManager;
 
 const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
 const CONTENT_SCRIPT_INJECTION_HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
+// TODO Bug 1470466: Use a different telemetry histogram key for the userScripts injection.
+const USER_SCRIPT_INJECTION_HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
 
 var apiManager = new class extends SchemaAPIManager {
   constructor() {
     super("content", Schemas);
     this.initialized = false;
   }
 
   lazyInit() {
@@ -464,34 +466,17 @@ class Script {
         // the stylesheets on first load. We should fix this up if it does becomes
         // a problem.
         if (this.css.length > 0) {
           context.contentWindow.document.blockParsing(cssPromise, {blockScriptCreated: false});
         }
       }
     }
 
-    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,
-      // and we haven't already missed that point, block further parsing
-      // until the scripts have been loaded.
-      let {document} = context.contentWindow;
-      if (this.runAt === "document_start" && document.readyState !== "complete") {
-        document.blockParsing(promise, {blockScriptCreated: false});
-      }
-
-      scripts = await promise;
-    }
+    let scripts = await this.awaitCompiledScripts(context);
 
     let result;
 
     // The evaluations below may throw, in which case the promise will be
     // automatically rejected.
     TelemetryStopwatch.start(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
     try {
       for (let script of scripts) {
@@ -503,16 +488,124 @@ class Script {
       }
     } finally {
       TelemetryStopwatch.finish(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
     }
 
     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,
+      // and we haven't already missed that point, block further parsing
+      // until the scripts have been loaded.
+      let {document} = context.contentWindow;
+      if (this.runAt === "document_start" && document.readyState !== "complete") {
+        document.blockParsing(promise, {blockScriptCreated: false});
+      }
+
+      scripts = await promise;
+    }
+
+    return scripts;
+  }
+}
+
+// Represents a user script.
+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.scriptPromises = null;
+
+    // WeakMap<ContentScriptContextChild, Sandbox>
+    this.sandboxes = new DefaultWeakMap((context) => {
+      return this.createSandbox(context);
+    });
+  }
+
+  compileScripts() {
+    if (!this.scriptPromises) {
+      this.scriptPromises = this.js.map(url => this.scriptCache.get(url));
+    }
+
+    return this.scriptPromises;
+  }
+
+  async inject(context) {
+    DocumentManager.lazyInit();
+
+    let sandboxScripts = await this.awaitCompiledScripts(context);
+
+    // 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);
+        },
+      });
+
+      for (let script of sandboxScripts) {
+        script.executeInGlobal(userScriptSandbox);
+      }
+    } finally {
+      TelemetryStopwatch.finish(USER_SCRIPT_INJECTION_HISTOGRAM, context);
+    }
+  }
+
+  createSandbox(context) {
+    const {contentWindow} = context;
+    const contentPrincipal = contentWindow.document.nodePrincipal;
+    const ssm = Services.scriptSecurityManager;
+
+    let principal;
+    if (ssm.isSystemPrincipal(contentPrincipal)) {
+      principal = ssm.createNullPrincipal(contentPrincipal.originAttributes);
+    } else {
+      principal = [contentPrincipal];
+    }
+
+    const sandbox = Cu.Sandbox(principal, {
+      sandboxName: `User Script registered by ${this.extension.policy.debugName}`,
+      sandboxPrototype: contentWindow,
+      sameZoneAs: contentWindow,
+      wantXrays: true,
+      wantGlobalProperties: ["XMLHttpRequest", "fetch"],
+      originAttributes: contentPrincipal.originAttributes,
+      metadata: {
+        "inner-window-id": context.innerWindowID,
+        addonId: this.extension.policy.id,
+      },
+    });
+
+    return sandbox;
+  }
 }
 
 /**
  * An execution context for semi-privileged extension content scripts.
  *
  * This is the child side of the ContentScriptContextParent class
  * defined in ExtensionParent.jsm.
  */
@@ -648,16 +741,17 @@ class ContentScriptContextChild extends 
       // Overwrite the content script APIs with an empty object if the APIs objects are still
       // defined in the content window (See Bug 1214658).
       if (this.isExtensionPage) {
         Cu.createObjectIn(this.contentWindow, {defineAs: "browser"});
         Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"});
       }
     }
     Cu.nukeSandbox(this.sandbox);
+
     this.sandbox = null;
   }
 }
 
 defineLazyGetter(ContentScriptContextChild.prototype, "messenger", function() {
   // The |sender| parameter is passed directly to the extension.
   let sender = {id: this.extension.id, frameId: this.frameId, url: this.url};
   let filter = {extensionId: this.extension.id};
@@ -778,16 +872,17 @@ DocumentManager = {
   initExtensionContext(extension, window) {
     extension.getContext(window).injectAPI();
   },
 };
 
 var ExtensionContent = {
   BrowserExtensionContent,
   Script,
+  UserScript,
 
   shutdownExtension(extension) {
     DocumentManager.shutdownExtension(extension);
   },
 
   // This helper is exported to be integrated in the devtools RDP actors,
   // that can use it to retrieve the existent WebExtensions ContentScripts
   // of a target window and be able to show the ContentScripts source in the
--- a/toolkit/components/extensions/child/ext-toolkit.js
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -60,16 +60,23 @@ extensions.registerModules({
   },
   test: {
     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"],
+    paths: [
+      ["userScripts"],
+    ],
+  },
   webRequest: {
     url: "chrome://extensions/content/child/ext-webRequest.js",
     scopes: ["addon_child"],
     paths: [
       ["webRequest"],
     ],
   },
 });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-userScripts.js
@@ -0,0 +1,156 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.importGlobalProperties(["crypto", "TextEncoder"]);
+
+var {
+  DefaultMap,
+  ExtensionError,
+} = ExtensionUtils;
+
+/**
+ * Represents a registered userScript in the child extension process.
+ *
+ * @param {ExtensionPageContextChild} context
+ *        The extension context which has registered the user script.
+ * @param {string} scriptId
+ *        An unique id that represents the registered user script
+ *        (generated and used internally to identify it across the different processes).
+ */
+class UserScriptChild {
+  constructor({context, scriptId, onScriptUnregister}) {
+    this.context = context;
+    this.scriptId = scriptId;
+    this.onScriptUnregister = onScriptUnregister;
+    this.unregistered = false;
+  }
+
+  async unregister() {
+    if (this.unregistered) {
+      throw new ExtensionError("User script already unregistered");
+    }
+
+    this.unregistered = true;
+
+    await this.context.childManager.callParentAsyncFunction(
+      "userScripts.unregister", [this.scriptId]);
+
+    this.context = null;
+
+    this.onScriptUnregister();
+  }
+
+  api() {
+    const {context} = this;
+
+    // Returns the RegisteredUserScript API object.
+    return {
+      unregister: () => {
+        return context.wrapPromise(this.unregister());
+      },
+    };
+  }
+}
+
+this.userScripts = class extends ExtensionAPI {
+  getAPI(context) {
+    // Cache of the script code already converted into blob urls:
+    //   Map<textHash, blobURLs>
+    const blobURLsByHash = new Map();
+
+    // Keep track of the userScript that are sharing the same blob urls,
+    // so that we can revoke any blob url that is not used by a registered
+    // userScripts:
+    //   Map<blobURL, Set<scriptId>>
+    const userScriptsByBlobURL = new DefaultMap(() => new Set());
+
+    function trackBlobURLs(scriptId, options) {
+      for (let url of options.js) {
+        if (userScriptsByBlobURL.has(url)) {
+          userScriptsByBlobURL.get(url).add(scriptId);
+        }
+      }
+    }
+
+    function revokeBlobURLs(scriptId, options) {
+      for (let url of options.js) {
+        if (userScriptsByBlobURL.has(url)) {
+          let scriptIds = userScriptsByBlobURL.get(url);
+          scriptIds.delete(scriptId);
+          if (scriptIds.size === 0) {
+            userScriptsByBlobURL.delete(url);
+            context.cloneScope.URL.revokeObjectURL(url);
+          }
+        }
+      }
+    }
+
+    // Convert a script code string into a blob URL (and use a cached one
+    // if the script hash is already associated to a blob URL).
+    const getBlobURL = async (text) => {
+      // Compute the hash of the js code string and reuse the blob url if we already have
+      // for the same hash.
+      const buffer = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(text));
+      const hash = String.fromCharCode(...new Uint16Array(buffer));
+
+      let blobURL = blobURLsByHash.get(hash);
+
+      if (blobURL) {
+        return blobURL;
+      }
+
+      const blob = new context.cloneScope.Blob([text], {type: "text/javascript"});
+      blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+      // Start to track this blob URL.
+      userScriptsByBlobURL.get(blobURL);
+
+      blobURLsByHash.set(hash, blobURL);
+
+      return blobURL;
+    };
+
+    function convertToAPIObject(scriptId, options) {
+      const registeredScript = new UserScriptChild({
+        context, scriptId,
+        onScriptUnregister: () => revokeBlobURLs(scriptId, options),
+      });
+      trackBlobURLs(scriptId, options);
+
+      const scriptAPI = Cu.cloneInto(registeredScript.api(), context.cloneScope,
+                                     {cloneFunctions: true});
+      return scriptAPI;
+    }
+
+    // Revoke all the created blob urls once the context is destroyed.
+    context.callOnClose({
+      close() {
+        if (!context.cloneScope) {
+          return;
+        }
+
+        for (let blobURL of blobURLsByHash.values()) {
+          context.cloneScope.URL.revokeObjectURL(blobURL);
+        }
+      },
+    });
+
+    return {
+      userScripts: {
+        register(options) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            options.js = await Promise.all(options.js.map(js => {
+              return js.file || getBlobURL(js.code);
+            }));
+
+            const scriptId = await context.childManager.callParentAsyncFunction(
+              "userScripts.register", [options]);
+
+            return convertToAPIObject(scriptId, options);
+          });
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -184,16 +184,24 @@
   "topSites": {
     "url": "chrome://extensions/content/parent/ext-topSites.js",
     "schema": "chrome://extensions/content/schemas/top_sites.json",
     "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"],
+    "paths": [
+      ["userScripts"]
+    ]
+  },
   "webNavigation": {
     "url": "chrome://extensions/content/parent/ext-webNavigation.js",
     "schema": "chrome://extensions/content/schemas/web_navigation.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["webNavigation"]
     ]
   },
--- a/toolkit/components/extensions/extension-process-script.js
+++ b/toolkit/components/extensions/extension-process-script.js
@@ -79,18 +79,23 @@ var extensions = new DefaultWeakMap(poli
   }
 
   let extension = new ExtensionChild.BrowserExtensionContent(data);
   extension.policy = policy;
   return extension;
 });
 
 var contentScripts = new DefaultWeakMap(matcher => {
-  return new ExtensionContent.Script(extensions.get(matcher.extension),
-                                     matcher);
+  const extension = extensions.get(matcher.extension);
+
+  if ("userScriptOptions" in matcher) {
+    return new ExtensionContent.UserScript(extension, matcher);
+  }
+
+  return new ExtensionContent.Script(extension, matcher);
 });
 
 var DocumentManager;
 var ExtensionManager;
 
 class ExtensionGlobal {
   constructor(global) {
     this.global = global;
@@ -351,16 +356,22 @@ ExtensionManager = {
       // a content process that crashed and it has been recreated).
       const registeredContentScripts = this.registeredContentScripts.get(policy);
 
       for (let [scriptId, options] of getData(extension, "contentScripts") || []) {
         const parsedOptions = parseScriptOptions(options, restrictSchemes);
         const script = new WebExtensionContentScript(policy, parsedOptions);
         policy.registerContentScript(script);
         registeredContentScripts.set(scriptId, script);
+
+        // If the script is a userScript, store the additional properties
+        // in the userScripts Weakmap.
+        if (options.user_script_options) {
+          script.userScriptOptions = options.user_script_options;
+        }
       }
 
       policy.active = true;
       policy.initData = extension;
     }
     return policy;
   },
 
@@ -404,26 +415,33 @@ ExtensionManager = {
         break;
       }
 
       case "Extension:RegisterContentScript": {
         let policy = WebExtensionPolicy.getByID(data.id);
 
         if (policy) {
           const registeredContentScripts = this.registeredContentScripts.get(policy);
+          const type = data.options.user_script_options ? "userScript" : "contentScript";
 
           if (registeredContentScripts.has(data.scriptId)) {
             Cu.reportError(new Error(
-              `Registering content script ${data.scriptId} on ${data.id} more than once`));
+              `Registering ${type} ${data.scriptId} on ${data.id} more than once`));
           } else {
             try {
               const parsedOptions = parseScriptOptions(data.options, !policy.hasPermission("mozillaAddons"));
               const script = new WebExtensionContentScript(policy, parsedOptions);
               policy.registerContentScript(script);
               registeredContentScripts.set(data.scriptId, script);
+
+              // If the script is a userScript, add the additional userScriptOptions
+              // property to the WebExtensionContentScript instance.
+              if (type === "userScript") {
+                script.userScriptOptions = data.options.user_script_options;
+              }
             } catch (e) {
               Cu.reportError(e);
             }
           }
         }
 
         Services.cpmm.sendAsyncMessage("Extension:RegisterContentScriptComplete");
         break;
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -30,21 +30,23 @@ toolkit.jar:
     content/extensions/parent/ext-proxy.js (parent/ext-proxy.js)
     content/extensions/parent/ext-runtime.js (parent/ext-runtime.js)
     content/extensions/parent/ext-storage.js (parent/ext-storage.js)
     content/extensions/parent/ext-tabs-base.js (parent/ext-tabs-base.js)
     content/extensions/parent/ext-telemetry.js (parent/ext-telemetry.js)
     content/extensions/parent/ext-theme.js (parent/ext-theme.js)
     content/extensions/parent/ext-toolkit.js (parent/ext-toolkit.js)
     content/extensions/parent/ext-topSites.js (parent/ext-topSites.js)
+    content/extensions/parent/ext-userScripts.js (parent/ext-userScripts.js)
     content/extensions/parent/ext-webRequest.js (parent/ext-webRequest.js)
     content/extensions/parent/ext-webNavigation.js (parent/ext-webNavigation.js)
     content/extensions/child/ext-backgroundPage.js (child/ext-backgroundPage.js)
     content/extensions/child/ext-contentScripts.js (child/ext-contentScripts.js)
     content/extensions/child/ext-extension.js (child/ext-extension.js)
 #ifndef ANDROID
     content/extensions/child/ext-identity.js (child/ext-identity.js)
 #endif
     content/extensions/child/ext-runtime.js (child/ext-runtime.js)
     content/extensions/child/ext-storage.js (child/ext-storage.js)
     content/extensions/child/ext-test.js (child/ext-test.js)
     content/extensions/child/ext-toolkit.js (child/ext-toolkit.js)
+    content/extensions/child/ext-userScripts.js (child/ext-userScripts.js)
     content/extensions/child/ext-webRequest.js (child/ext-webRequest.js)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-userScripts.js
@@ -0,0 +1,136 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var {
+  ExtensionError,
+  getUniqueId,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the main browser process) a user script.
+ *
+ * @param {UserScriptOptions} details
+ *        The options object related to the user script
+ *        (which has the properties described in the user_scripts.json
+ *        JSON API schema file).
+ */
+class UserScriptParent {
+  constructor(details) {
+    this.scriptId = getUniqueId();
+    this.options = this._convertOptions(details);
+  }
+
+  destroy() {
+    if (this.destroyed) {
+      throw new Error("Unable to destroy UserScriptParent twice");
+    }
+
+    this.destroyed = true;
+    this.options = null;
+  }
+
+  _convertOptions(details) {
+    const options = {
+      matches: details.matches,
+      exclude_matches: details.excludeMatches,
+      include_globs: details.includeGlobs,
+      exclude_globs: details.excludeGlobs,
+      all_frames: details.allFrames,
+      match_about_blank: details.matchAboutBlank,
+      run_at: details.runAt,
+      js: details.js,
+      user_script_options: {
+        scriptMetadata: details.scriptMetadata,
+      },
+    };
+
+    return options;
+  }
+
+  serialize() {
+    return this.options;
+  }
+}
+
+this.userScripts = class extends ExtensionAPI {
+  constructor(...args) {
+    super(...args);
+
+    // Map<scriptId -> UserScriptParent>
+    this.userScriptsMap = new Map();
+  }
+
+  getAPI(context) {
+    const {extension} = context;
+
+    // Set of the scriptIds registered from this context.
+    const registeredScriptIds = new Set();
+
+    function unregisterContentScripts(scriptIds) {
+      for (let scriptId of registeredScriptIds) {
+        extension.registeredContentScripts.delete(scriptId);
+        this.userScriptsMap.delete(scriptId);
+      }
+
+      return context.extension.broadcast("Extension:UnregisterContentScripts", {
+        id: context.extension.id,
+        scriptIds,
+      });
+    }
+
+    // Unregister all the scriptId related to a context when it is closed,
+    // and revoke all the created blob urls once the context is destroyed.
+    context.callOnClose({
+      close() {
+        unregisterContentScripts(Array.from(registeredScriptIds));
+      },
+    });
+
+    return {
+      userScripts: {
+        register: async (details) => {
+          for (let origin of details.matches) {
+            if (!extension.whiteListedHosts.subsumes(new MatchPattern(origin))) {
+              throw new ExtensionError(`Permission denied to register a user script for ${origin}`);
+            }
+          }
+
+          const userScript = new UserScriptParent(details);
+          const {scriptId} = userScript;
+
+          this.userScriptsMap.set(scriptId, userScript);
+
+          const scriptOptions = userScript.serialize();
+
+          await extension.broadcast("Extension:RegisterContentScript", {
+            id: extension.id,
+            options: scriptOptions,
+            scriptId,
+          });
+
+          extension.registeredContentScripts.set(scriptId, scriptOptions);
+
+          return scriptId;
+        },
+
+        // This method is not available to the extension code, the extension code
+        // doesn't have access to the internally used scriptId, on the contrary
+        // the extension code will call script.unregister on the script API object
+        // that is resolved from the register API method returned promise.
+        unregister: async (scriptId) => {
+          const userScript = this.userScriptsMap.get(scriptId);
+          if (!userScript) {
+            throw new Error(`No such user script ID: ${scriptId}`);
+          }
+
+          userScript.destroy();
+
+          await unregisterContentScripts([scriptId]);
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/schemas/content_scripts.json
+++ b/toolkit/components/extensions/schemas/content_scripts.json
@@ -2,37 +2,16 @@
  * 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": "contentScripts",
     "types": [
       {
-        "id": "ExtensionFileOrCode",
-        "choices": [
-          {
-            "type": "object",
-            "properties": {
-              "file": {
-                "$ref": "manifest.ExtensionURL"
-              }
-            }
-          },
-          {
-            "type": "object",
-            "properties": {
-              "code": {
-                "type": "string"
-              }
-            }
-          }
-        ]
-      },
-      {
         "id": "RegisteredContentScriptOptions",
         "type": "object",
         "description": "Details of a content script registered programmatically",
         "properties": {
           "matches": {
             "type": "array",
             "optional": false,
             "minItems": 1,
@@ -53,23 +32,23 @@
             "type": "array",
             "optional": true,
             "items": { "type": "string" }
           },
           "css": {
             "type": "array",
             "optional": true,
             "description": "The list of CSS files to inject",
-            "items": { "$ref": "ExtensionFileOrCode" }
+            "items": { "$ref": "extensionTypes.ExtensionFileOrCode" }
           },
           "js": {
             "type": "array",
             "optional": true,
             "description": "The list of JS files to inject",
-            "items": { "$ref": "ExtensionFileOrCode" }
+            "items": { "$ref": "extensionTypes.ExtensionFileOrCode" }
           },
           "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
           "matchAboutBlank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
           "runAt": {
             "$ref": "extensionTypes.RunAt",
             "optional": true,
             "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
           }
--- a/toolkit/components/extensions/schemas/extension_types.json
+++ b/toolkit/components/extensions/schemas/extension_types.json
@@ -84,12 +84,44 @@
             "minimum": 0
           },
           {
             "type": "object",
             "isInstanceOf": "Date",
             "additionalProperties": { "type": "any" }
           }
         ]
+      },
+      {
+        "id": "ExtensionFileOrCode",
+        "choices": [
+          {
+            "type": "object",
+            "properties": {
+              "file": {
+                "$ref": "manifest.ExtensionURL"
+              }
+            }
+          },
+          {
+            "type": "object",
+            "properties": {
+              "code": {
+                "type": "string"
+              }
+            }
+          }
+        ]
+      },
+      {
+        "id": "PlainJSONValue",
+        "description": "A plain JSON value",
+        "choices": [
+          { "type": "number" },
+          { "type": "string" },
+          { "type": "boolean" },
+          { "type": "array", "items": {"$ref": "PlainJSONValue"} },
+          { "type": "object", "additionalProperties": { "$ref": "PlainJSONValue" } }
+        ]
       }
     ]
   }
 ]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -31,10 +31,11 @@ toolkit.jar:
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.json
     content/extensions/schemas/telemetry.json
     content/extensions/schemas/test.json
     content/extensions/schemas/theme.json
     content/extensions/schemas/top_sites.json
     content/extensions/schemas/types.json
+    content/extensions/schemas/user_scripts.json
     content/extensions/schemas/web_navigation.json
     content/extensions/schemas/web_request.json
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/user_scripts.json
@@ -0,0 +1,98 @@
+/* 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": "userScripts",
+    "types": [
+      {
+        "id": "UserScriptOptions",
+        "type": "object",
+        "description": "Details of a user script",
+        "properties": {
+          "js": {
+            "type": "array",
+            "optional": true,
+            "description": "The list of JS files to inject",
+            "minItems": 1,
+            "items": { "$ref": "extensionTypes.ExtensionFileOrCode" }
+          },
+          "scriptMetadata": {
+            "description": "An opaque user script metadata value",
+            "$ref": "extensionTypes.PlainJSONValue",
+            "optional": true
+          },
+          "matches": {
+            "type": "array",
+            "optional": false,
+            "minItems": 1,
+            "items": { "$ref": "manifest.MatchPattern" }
+          },
+          "excludeMatches": {
+            "type": "array",
+            "optional": true,
+            "minItems": 1,
+            "items": { "$ref": "manifest.MatchPattern" }
+          },
+          "includeGlobs": {
+            "type": "array",
+            "optional": true,
+            "items": { "type": "string" }
+          },
+          "excludeGlobs": {
+            "type": "array",
+            "optional": true,
+            "items": { "type": "string" }
+          },
+          "allFrames": {
+            "type": "boolean",
+            "default": false,
+            "optional": true,
+            "description": "If allFrames is <code>true</code>, implies that the JavaScript should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."
+          },
+          "matchAboutBlank": {
+            "type": "boolean",
+            "default": false,
+            "optional": true,
+            "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."
+          },
+          "runAt": {
+            "$ref": "extensionTypes.RunAt",
+            "default": "document_idle",
+            "optional": true,
+            "description": "The soonest that the JavaScript will be injected into the tab. Defaults to \"document_idle\"."
+          }
+        }
+      },
+      {
+        "id": "RegisteredUserScript",
+        "type": "object",
+        "description": "An object that represents a user script registered programmatically",
+        "functions": [
+          {
+            "name": "unregister",
+            "type": "function",
+            "description": "Unregister a user script registered programmatically",
+            "async": true,
+            "parameters": []
+          }
+        ]
+      }
+    ],
+    "functions": [
+      {
+        "name": "register",
+        "type": "function",
+        "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"
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -92,16 +92,17 @@ let expectedBackgroundApis = [
   "runtime.onUpdateAvailable",
   "runtime.openOptionsPage",
   "runtime.reload",
   "runtime.setUninstallURL",
   "theme.getCurrent",
   "theme.onUpdated",
   "types.LevelOfControl",
   "types.SettingScope",
+  "userScripts.register",
 ];
 
 function sendAllApis() {
   function isEvent(key, val) {
     if (!/^on[A-Z]/.test(key)) {
       return false;
     }
     let eventKeys = [];
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_handling_user_input.js
@@ -0,0 +1,39 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported withHandlingUserInput */
+
+ChromeUtils.import("resource://gre/modules/MessageChannel.jsm");
+
+let extensionHandlers = new WeakSet();
+
+function handlingUserInputFrameScript() {
+  /* globals content */
+  ChromeUtils.import("resource://gre/modules/MessageChannel.jsm");
+
+  let handle;
+  MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", {
+    receiveMessage({name, data}) {
+      if (data) {
+        handle = content.windowUtils.setHandlingUserInput(true);
+      } else if (handle) {
+        handle.destruct();
+        handle = null;
+      }
+    },
+  });
+}
+
+async function withHandlingUserInput(extension, fn) {
+  let {messageManager} = extension.extension.groupFrameLoader;
+
+  if (!extensionHandlers.has(extension)) {
+    messageManager.loadFrameScript(`data:,(${handlingUserInputFrameScript}).call(this)`, false, true);
+    extensionHandlers.add(extension);
+  }
+
+  await MessageChannel.sendMessage(messageManager, "ExtensionTest:HandleUserInput", true);
+  await fn();
+  await MessageChannel.sendMessage(messageManager, "ExtensionTest:HandleUserInput", false);
+}
--- a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
@@ -1,55 +1,22 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionPermissions.jsm");
-ChromeUtils.import("resource://gre/modules/MessageChannel.jsm");
 ChromeUtils.import("resource://gre/modules/osfile.jsm");
 
 const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties";
 
 AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
 
 Services.prefs.setBoolPref("extensions.webextensions.background-delayed-startup", false);
 
-let extensionHandlers = new WeakSet();
-
-function frameScript() {
-  /* globals content */
-  ChromeUtils.import("resource://gre/modules/MessageChannel.jsm");
-
-  let handle;
-  MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", {
-    receiveMessage({name, data}) {
-      if (data) {
-        handle = content.windowUtils.setHandlingUserInput(true);
-      } else if (handle) {
-        handle.destruct();
-        handle = null;
-      }
-    },
-  });
-}
-
-async function withHandlingUserInput(extension, fn) {
-  let {messageManager} = extension.extension.groupFrameLoader;
-
-  if (!extensionHandlers.has(extension)) {
-    messageManager.loadFrameScript(`data:,(${frameScript}).call(this)`, false, true);
-    extensionHandlers.add(extension);
-  }
-
-  await MessageChannel.sendMessage(messageManager, "ExtensionTest:HandleUserInput", true);
-  await fn();
-  await MessageChannel.sendMessage(messageManager, "ExtensionTest:HandleUserInput", false);
-}
-
 let sawPrompt = false;
 let acceptPrompt = false;
 const observer = {
   observe(subject, topic, data) {
     if (topic == "webextension-optional-permission-prompt") {
       sawPrompt = true;
       let {resolve} = subject.wrappedJSObject;
       resolve(acceptPrompt);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -0,0 +1,203 @@
+"use strict";
+
+const {
+  createAppInfo,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function setup_optional_permission_observer() {
+  // Grant the optional permissions requested.
+  function permissionObserver(subject, topic, data) {
+    if (topic == "webextension-optional-permission-prompt") {
+      let {resolve} = subject.wrappedJSObject;
+      resolve(true);
+    }
+  }
+  Services.obs.addObserver(permissionObserver, "webextension-optional-permission-prompt");
+  registerCleanupFunction(() => {
+    Services.obs.removeObserver(permissionObserver, "webextension-optional-permission-prompt");
+  });
+});
+
+// Test that userScripts can only matches origins that are subsumed by the extension permissions,
+// and that more origins can be allowed by requesting an optional permission.
+add_task(async function test_userScripts_matches_denied() {
+  async function background() {
+    async function registerUserScriptWithMatches(matches) {
+      const scripts = await browser.userScripts.register({
+        js: [{code: ""}],
+        matches,
+      });
+      await scripts.unregister();
+    }
+
+    // These matches are supposed to be denied until the extension has been granted the
+    // <all_urls> origin permission.
+    const testMatches = [
+      "<all_urls>",
+      "file://*/*",
+      "https://localhost/*",
+      "http://example.com/*",
+    ];
+
+    browser.test.onMessage.addListener(async msg => {
+      if (msg === "test-denied-matches") {
+        for (let testMatch of testMatches) {
+          await browser.test.assertRejects(
+            registerUserScriptWithMatches([testMatch]),
+            /Permission denied to register a user script for/,
+            "Got the expected rejection when the extension permission does not subsume the userScript matches");
+        }
+      } else if (msg === "grant-all-urls") {
+        await browser.permissions.request({origins: ["<all_urls>"]});
+      } else if (msg === "test-allowed-matches") {
+        for (let testMatch of testMatches) {
+          try {
+            await registerUserScriptWithMatches([testMatch]);
+          } catch (err) {
+            browser.test.fail(`Unexpected rejection ${err} on matching ${JSON.stringify(testMatch)}`);
+          }
+        }
+      }
+
+      browser.test.sendMessage(`${msg}:done`);
+    });
+
+    browser.test.sendMessage("background-ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["http://localhost/*"],
+      optional_permissions: ["<all_urls>"],
+    },
+    background,
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage("background-ready");
+
+  // Test that the matches not subsumed by the extension permissions are being denied.
+  extension.sendMessage("test-denied-matches");
+  await extension.awaitMessage("test-denied-matches:done");
+
+  // Grant the optional <all_urls> permission.
+  await withHandlingUserInput(extension, async () => {
+    extension.sendMessage("grant-all-urls");
+    await extension.awaitMessage("grant-all-urls:done");
+  });
+
+  // Test that all the matches are now subsumed by the extension permissions.
+  extension.sendMessage("test-allowed-matches");
+  await extension.awaitMessage("test-allowed-matches:done");
+
+  await extension.unload();
+});
+
+// Test that userScripts sandboxes:
+// - can be registered/unregistered from an extension page
+// - have no WebExtensions APIs available
+// - are able to access the target window and document
+add_task(async function test_userScripts_no_webext_apis() {
+  async function background() {
+    const matches = ["http://localhost/*/file_sample.html"];
+
+    const script = await browser.userScripts.register({
+      js: [{
+        code: `
+          const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
+          document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces);
+        `,
+      }],
+      runAt: "document_end",
+      matches,
+      scriptMetadata: {
+        name: "test-user-script",
+        arrayToMatch: ["el1"],
+        objectToMatch: {nestedProp: "nestedValue"},
+      },
+    });
+
+    const scriptToRemove = await browser.userScripts.register({
+      js: [{
+        code: 'document.body.innerHTML = "unexpected unregistered userScript loaded";',
+      }],
+      runAt: "document_end",
+      matches,
+      scriptMetadata: {
+        name: "user-script-to-remove",
+      },
+    });
+
+    browser.test.assertTrue("unregister" in script,
+                            "Got an unregister method on the userScript API object");
+
+    // Remove the last registered user script.
+    await scriptToRemove.unregister();
+
+    await browser.contentScripts.register({
+      js: [{
+        code: `
+          browser.test.sendMessage("page-loaded", {
+            textContent: document.body.textContent,
+            url: window.location.href,
+          }); true;
+        `,
+      }],
+      matches,
+    });
+
+    browser.test.sendMessage("background-ready");
+  }
+
+  let extensionData = {
+    manifest: {
+      permissions: [
+        "http://localhost/*/file_sample.html",
+      ],
+    },
+    background,
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+  await extension.startup();
+
+  await extension.awaitMessage("background-ready");
+
+  // Test in an existing process (where the registered userScripts has been received from the
+  // Extension:RegisterContentScript message sent to all the processes).
+  info("Test content script loaded in a process created before any registered userScript");
+  let url = `${BASE_URL}/file_sample.html#remote-false`;
+  let contentPage = await ExtensionTestUtils.loadContentPage(url, {remote: false});
+  const reply = await extension.awaitMessage("page-loaded");
+  Assert.deepEqual(reply, {
+    textContent: "userScript loaded - undefined",
+    url,
+  }, "The userScript executed on the expected url and no access to the WebExtensions APIs");
+  await contentPage.close();
+
+  // Test in a new process (where the registered userScripts has to be retrieved from the extension
+  // representation from the shared memory data).
+  info("Test content script loaded in a process created after the userScript has been registered");
+  let url2 = `${BASE_URL}/file_sample.html#remote-true`;
+  let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, {remote: true});
+  // Load an url that matches and check that the userScripts has been loaded.
+  const reply2 = await extension.awaitMessage("page-loaded");
+  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();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -118,16 +118,17 @@ skip-if = os == "android" # checking for
 [test_ext_tab_teardown.js]
 skip-if = os == 'android' # Bug 1258975 on android.
 [test_ext_telemetry.js]
 [test_ext_trustworthy_origin.js]
 [test_ext_topSites.js]
 skip-if = os == "android"
 [test_ext_unload_frame.js]
 skip-if = true # Too frequent intermittent failures
+[test_ext_userScripts.js]
 [test_ext_webRequest_auth.js]
 [test_ext_webRequest_filterResponseData.js]
 [test_ext_webRequest_permission.js]
 [test_ext_webRequest_responseBody.js]
 [test_ext_webRequest_set_cookie.js]
 skip-if = appname == "thunderbird"
 [test_ext_webRequest_startup.js]
 [test_ext_webRequest_suspend.js]
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
@@ -1,10 +1,10 @@
 [DEFAULT]
-head = head.js head_remote.js head_e10s.js head_telemetry.js head_storage.js
+head = head.js head_remote.js head_e10s.js head_telemetry.js head_storage.js head_handling_user_input.js
 tail =
 firefox-appdir = browser
 skip-if = appname == "thunderbird" || os == "android"
 dupe-manifest =
 support-files =
   data/**
   xpcshell-content.ini
 tags = webextensions remote-webextensions
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,10 +1,10 @@
 [DEFAULT]
-head = head.js head_telemetry.js head_storage.js
+head = head.js head_telemetry.js head_storage.js head_handling_user_input.js
 firefox-appdir = browser
 dupe-manifest =
 support-files =
   data/**
   head_sync.js
   xpcshell-content.ini
 tags = webextensions in-process-webextensions