--- 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