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 809612 6e9f6311a9a93c2753fdf8eb4be312e0574327ec
parent 809559 6b6f3f6ecf142908b3e437d3bc3fac75540a9bcb
child 809619 d81a9bb4062cbeb611369d598b7c6e369b015189
push id113727
push userbmo:lgreco@mozilla.com
push dateFri, 22 Jun 2018 15:17:19 +0000
bugs1437861
milestone62.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/jar.mn
toolkit/components/extensions/schemas/user_scripts.json
toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -496,16 +496,117 @@ class Script {
       TelemetryStopwatch.finish(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
     }
 
     await cssPromise;
     return result;
   }
 }
 
+// Represents a user script.
+class UserScript extends Script {
+  constructor(extension, matcher, userScriptOptions) {
+    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));
+    }
+  }
+
+  async inject(context) {
+    DocumentManager.lazyInit();
+
+    this.compileScripts();
+
+    let sandboxScripts = this.scriptPromises.map(promise => promise.script);
+
+    // If not all scripts are already available in the cache, block
+    // parsing and wait all promises to resolve.
+    if (!sandboxScripts.every(script => script)) {
+      const promise = Promise.all(this.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.
+      const {document} = context.contentWindow;
+      if (this.runAt === "document_start" && document.readyState !== "complete") {
+        document.blockParsing(promise, {blockScriptCreated: false});
+      }
+
+      // Destructure the Promise.all resolved array to strip the api script.
+      sandboxScripts = await promise;
+    }
+
+    // The evaluations below may throw, in which case the promise will be
+    // automatically rejected.
+    // TODO: use a separate telemetry key for user script injection?
+    TelemetryStopwatch.start(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
+    try {
+      context.callOnClose({
+        close: () => {
+          // Destroy the userScript sandbox when the related ContentScriptContextChild instance
+          // is being closed.
+          if (this.sandboxes.has(context)) {
+            let sandbox = this.sandboxes.get(context);
+            Cu.nukeSandbox(sandbox);
+            this.sandboxes.delete(context);
+          }
+        },
+      });
+
+      // Lazily create a userScript sandbox for the current target window.
+      let userScriptSandbox = this.sandboxes.get(context);
+
+      // Evaluate the userScript code in its isolated sandbox.
+      for (let script of sandboxScripts) {
+        script.executeInGlobal(userScriptSandbox);
+      }
+    } finally {
+      TelemetryStopwatch.finish(CONTENT_SCRIPT_INJECTION_HISTOGRAM, context);
+    }
+  }
+
+  createSandbox(context) {
+    const window = context.contentWindow;
+
+    // TODO Bug 1437867: here is where the sandbox options could be customized using the
+    // userScript sandboxOptions (e.g. optionally avoid to override the XMLHttpRequest and
+    // fetch globals based on the userScript sandboxOptions).
+    const contentPrincipal = window.document.nodePrincipal;
+    const sandbox = Cu.Sandbox(window, {
+      principal: [contentPrincipal],
+      sandboxName: `User Script registered by ${this.extension.policy.debugName}`,
+      sandboxPrototype: window,
+      sameZoneAs: window,
+      wantXrays: true,
+      isWebExtensionContentScript: true,
+      wantGlobalProperties: ["XMLHttpRequest", "fetch"],
+      originAttributes: contentPrincipal.originAttributes,
+    });
+
+    // Override the JSON, XMLHttpRequest and fetch globals.
+    Cu.evalInSandbox(`
+      window.JSON = JSON;
+      window.XMLHttpRequest = XMLHttpRequest;
+      window.fetch = fetch;
+    `, sandbox);
+
+    return sandbox;
+  }
+}
+
 /**
  * An execution context for semi-privileged extension content scripts.
  *
  * This is the child side of the ContentScriptContextParent class
  * defined in ExtensionParent.jsm.
  */
 class ContentScriptContextChild extends BaseContext {
   constructor(extension, contentWindow) {
@@ -642,16 +743,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};
@@ -772,16 +874,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,130 @@
+/* -*- 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 {
+  ExtensionError,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a user script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @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) {
+    this.context = context;
+    this.scriptId = scriptId;
+    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;
+  }
+
+  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 UserScriptChild API objects:
+    //   Map<scriptId, extensionAPIObject>
+    const scriptsAPICache = new Map();
+
+    // Cache of the script code already converted into blob urls:
+    //   Map<textHash, blobURLs>
+    const blobURLsByHash = new Map();
+
+    // Convert a scriptId into a RegisteredUserScript API object (and add it
+    // to the cached ones).
+    function convertToAPIObject(scriptId) {
+      const registeredScript = new UserScriptChild(context, scriptId);
+      const scriptAPI = Cu.cloneInto(registeredScript.api(), context.cloneScope,
+                                     {cloneFunctions: true});
+
+      scriptsAPICache.set(scriptId, scriptAPI);
+
+      return scriptAPI;
+    }
+
+    // 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 convertStringToBlobURL = async (text, mime) => {
+      // Compute the hash of the js code string and we already have a blob url for it.
+      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: mime});
+      blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+      blobURLsByHash.set(hash, blobURL);
+
+      return blobURL;
+    };
+
+    // Revoke all the created blob urls once the context is destroyed.
+    context.callOnClose({
+      close: async () => {
+        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 => {
+              if (js.file) {
+                return js.file;
+              }
+
+              return convertStringToBlobURL(js.code, "text/javascript");
+            }));
+
+            const scriptId = await context.childManager.callParentAsyncFunction(
+              "userScripts.register", [options]);
+
+            return convertToAPIObject(scriptId);
+          });
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -176,16 +176,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
@@ -71,17 +71,33 @@ var extensions = new DefaultWeakMap(poli
     data = data.serialize();
   }
 
   let extension = new ExtensionChild.BrowserExtensionContent(data);
   extension.policy = policy;
   return extension;
 });
 
+// NOTE: This is temporary until prototyping is further along (this WeakMap can probably
+// be removed by including some additional properties on WebExtensionContentScript).
+//
+// WeakMap of the userScripts options, which associates a WebExtensionContentScript
+// instance, used to match the loaded web pages, with the actual userScript options:
+//
+//   WeakMap<WebExtensionContentScript, UserScriptOptions>
+var userScripts = new WeakMap();
+
 var contentScripts = new DefaultWeakMap(matcher => {
+  if (userScripts.has(matcher)) {
+    let userScriptOptions = userScripts.get(matcher);
+
+    return new ExtensionContent.UserScript(extensions.get(matcher.extension), matcher,
+                                           userScriptOptions);
+  }
+
   return new ExtensionContent.Script(extensions.get(matcher.extension),
                                      matcher);
 });
 
 function getMessageManager(window) {
   let docShell = window.document.docShell.QueryInterface(Ci.nsIInterfaceRequestor);
   try {
     return docShell.getInterface(Ci.nsIContentFrameMessageManager);
@@ -363,16 +379,22 @@ ExtensionManager = {
       const registeredContentScripts = this.registeredContentScripts.get(policy);
 
       if (extension.registeredContentScripts) {
         for (let [scriptId, options] of extension.registeredContentScripts) {
           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.userScriptOptions) {
+            userScripts.set(script, options.userScriptOptions);
+          }
         }
       }
 
       policy.active = true;
       policy.initData = extension;
     }
     return policy;
   },
@@ -433,24 +455,31 @@ ExtensionManager = {
 
       case "Extension:RegisterContentScript": {
         let policy = WebExtensionPolicy.getByID(data.id);
 
         if (policy) {
           const registeredContentScripts = this.registeredContentScripts.get(policy);
 
           if (registeredContentScripts.has(data.scriptId)) {
+            const type = data.options.userScriptOptions ? "user" : "content";
             Cu.reportError(new Error(
-              `Registering content script ${data.scriptId} on ${data.id} more than once`));
+              `Registering ${type} script ${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, store the additional properties
+              // in the userScripts Weakmap.
+              if (data.options.userScriptOptions) {
+                userScripts.set(script, data.options.userScriptOptions);
+              }
             } catch (e) {
               Cu.reportError(e);
             }
           }
         }
 
         Services.cpmm.sendAsyncMessage("Extension:RegisterContentScriptComplete");
         break;
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -29,21 +29,23 @@ toolkit.jar:
     content/extensions/parent/ext-protocolHandlers.js (parent/ext-protocolHandlers.js)
     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-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,149 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported registerUserScript, unregisterUserScript */
+/* global registerUserScript, unregisterUserScript */
+
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+ChromeUtils.import("resource://gre/modules/ObjectUtils.jsm");
+
+var {
+  ExtensionError,
+  getUniqueId,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the main browser process) a user script
+ * @param {ProxyContextParent} context
+ *        The parent proxy context related to the extension context which
+ *        has registered the 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.context = null;
+    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 || "document_idle",
+      js: details.js,
+      userScriptOptions: {
+        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();
+
+    // 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: async () => {
+        const scriptIds = Array.from(registeredScriptIds);
+
+        for (let scriptId of registeredScriptIds) {
+          extension.registeredContentScripts.delete(scriptId);
+          this.userScriptsMap.delete(scriptId);
+        }
+
+        await context.extension.broadcast("Extension:UnregisterContentScripts", {
+          id: context.extension.id,
+          scriptIds,
+        });
+      },
+    });
+
+    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) {
+            Cu.reportError(new Error(`No such user script ID: ${scriptId}`));
+
+            return;
+          }
+
+          this.userScriptsMap.delete(scriptId);
+          extension.registeredContentScripts.delete(scriptId);
+
+          userScript.destroy();
+
+          await extension.broadcast("Extension:UnregisterContentScripts", {
+            id: extension.id,
+            scriptIds: [scriptId],
+          });
+        },
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -30,10 +30,11 @@ toolkit.jar:
     content/extensions/schemas/proxy.json
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.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,100 @@
+/* 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": "ScriptMetadataPropertyType",
+        "description": "Allowed script metadata property types",
+        "choices": [
+          { "type": "number" },
+          { "type": "string" },
+          { "type": "boolean" },
+          { "type": "array", "items": {"$ref": "ScriptMetadataPropertyType"} },
+          { "type": "object", "additionalProperties": { "$ref": "ScriptMetadataPropertyType" } }
+        ]
+      },
+      {
+        "id": "ScriptMetadata",
+        "type": "object",
+        "description": "An opaque set of user script metadata",
+        "additionalProperties": { "$ref": "ScriptMetadataPropertyType" }
+      },
+      {
+        "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": "contentScripts.ExtensionFileOrCode" }
+          },
+          "scriptMetadata": { "$ref": "ScriptMetadata", "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", "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\"."
+          }
+        }
+      },
+      {
+        "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)",
+        "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/test_ext_userScripts.js
@@ -0,0 +1,97 @@
+"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`;
+
+// Test that userScripts sandboxes:
+// - can be registered/unregistered/listed 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", document.body.textContent); true;`,
+      }],
+      matches,
+    });
+
+    browser.test.sendMessage("background-ready");
+  }
+
+  let extensionData = {
+    manifest: {
+      permissions: [
+        "http://localhost/*/file_sample.html",
+      ],
+    },
+    background,
+  };
+
+  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`);
+
+  const bodyText = await extension.awaitMessage("page-loaded");
+
+  equal(bodyText, "userScript loaded: undefined",
+        "The userScript has been executed and it has no WebExtensions APIs available as expected");
+
+  await contentPage.close();
+
+  await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -110,16 +110,17 @@ skip-if = os == "android"
 skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
 [test_ext_tab_teardown.js]
 skip-if = os == 'android' # Bug 1258975 on android.
 [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]
 [test_ext_webRequest_startup.js]
 [test_ext_webRequest_suspend.js]
 [test_ext_webRequest_webSocket.js]