Bug 1456485: Part 2 - Allow extensions with the mozillaAddons permission to match restricted schemes. r?zombie draft
authorKris Maglione <maglione.k@gmail.com>
Wed, 09 May 2018 18:55:59 -0700
changeset 795078 9dd8a48cc61456e59051271ac6c6e098b0239e3a
parent 795077 fd8d45e44eb9d1b540030a2ccc0141f775449d6c
push id109855
push usermaglione.k@gmail.com
push dateTue, 15 May 2018 00:21:15 +0000
reviewerszombie
bugs1456485
milestone62.0a1
Bug 1456485: Part 2 - Allow extensions with the mozillaAddons permission to match restricted schemes. r?zombie The schema handling for this is currently a bit ugly, for the sake of simplifying uplift. In the figure, we should find a way to change the schema pattern matching based on whether or not the extension is privileged. MozReview-Commit-ID: CU9WR2Ika6k
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/WebExtensionContentScript.h
toolkit/components/extensions/WebExtensionPolicy.cpp
toolkit/components/extensions/extension-process-script.js
toolkit/components/extensions/schemas/manifest.json
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
xpcom/ds/nsGkAtomList.h
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -505,16 +505,17 @@ class ExtensionData {
       permissions: newPermissions.permissions.filter(perm => !oldPermissions.permissions.includes(perm)),
     };
   }
 
   canUseExperiment(manifest) {
     return this.experimentsAllowed && manifest.experiment_apis;
   }
 
+  // eslint-disable-next-line complexity
   async parseManifest() {
     let [manifest] = await Promise.all([
       this.readJSON("manifest.json"),
       Management.lazyInit(),
     ]);
 
     this.manifest = manifest;
     this.rawManifest = manifest;
@@ -591,31 +592,38 @@ class ExtensionData {
       originPermissions,
       permissions,
       schemaURLs: null,
       type: this.type,
       webAccessibleResources,
     };
 
     if (this.type === "extension") {
+      let restrictSchemes = !(this.isPrivileged && manifest.permissions.includes("mozillaAddons"));
+
       for (let perm of manifest.permissions) {
         if (perm === "geckoProfiler" && !this.isPrivileged) {
           const acceptedExtensions = Services.prefs.getStringPref("extensions.geckoProfiler.acceptedExtensionIds", "");
           if (!acceptedExtensions.split(",").includes(id)) {
             this.manifestError("Only whitelisted extensions are allowed to access the geckoProfiler.");
             continue;
           }
         }
 
         let type = classifyPermission(perm);
         if (type.origin) {
-          let matcher = new MatchPattern(perm, {ignorePath: true});
+          try {
+            let matcher = new MatchPattern(perm, {restrictSchemes, ignorePath: true});
 
-          perm = matcher.pattern;
-          originPermissions.add(perm);
+            perm = matcher.pattern;
+            originPermissions.add(perm);
+          } catch (e) {
+            this.manifestWarning(`Invalid host permission: ${perm}`);
+            continue;
+          }
         } else if (type.api) {
           apiNames.add(type.api);
         }
 
         permissions.add(perm);
       }
 
       if (this.id) {
@@ -795,21 +803,38 @@ class ExtensionData {
     this.type = manifestData.type;
 
     this.modules = manifestData.modules;
 
     this.apiManager = this.getAPIManager();
     await this.apiManager.lazyInit();
 
     this.webAccessibleResources = manifestData.webAccessibleResources.map(res => new MatchGlob(res));
-    this.whiteListedHosts = new MatchPatternSet(manifestData.originPermissions);
+    this.whiteListedHosts = new MatchPatternSet(manifestData.originPermissions, {restrictSchemes: !this.hasPermission("mozillaAddons")});
 
     return this.manifest;
   }
 
+  hasPermission(perm, includeOptional = false) {
+    let manifest_ = "manifest:";
+    if (perm.startsWith(manifest_)) {
+      return this.manifest[perm.substr(manifest_.length)] != null;
+    }
+
+    if (this.permissions.has(perm)) {
+      return true;
+    }
+
+    if (includeOptional && this.manifest.optional_permissions.includes(perm)) {
+      return true;
+    }
+
+    return false;
+  }
+
   getAPIManager() {
     let apiManagers = [Management];
 
     for (let id of this.dependencies) {
       let policy = WebExtensionPolicy.getByID(id);
       if (policy) {
         apiManagers.push(policy.extension.experimentAPIManager);
       }
@@ -1269,17 +1294,17 @@ class Extension extends ExtensionData {
       for (let perm of permissions.permissions) {
         this.permissions.add(perm);
       }
 
       if (permissions.origins.length > 0) {
         let patterns = this.whiteListedHosts.patterns.map(host => host.pattern);
 
         this.whiteListedHosts = new MatchPatternSet(new Set([...patterns, ...permissions.origins]),
-                                                    {ignorePath: true});
+                                                    {restrictSchemes: !this.hasPermission("mozillaAddons"), ignorePath: true});
       }
 
       this.policy.permissions = Array.from(this.permissions);
       this.policy.allowedOrigins = this.whiteListedHosts;
 
       this.cachePermissions();
     });
 
@@ -1838,41 +1863,24 @@ class Extension extends ExtensionData {
   }
 
   observe(subject, topic, data) {
     if (topic === "xpcom-shutdown") {
       this.cleanupGeneratedFile();
     }
   }
 
-  hasPermission(perm, includeOptional = false) {
-    let manifest_ = "manifest:";
-    if (perm.startsWith(manifest_)) {
-      return this.manifest[perm.substr(manifest_.length)] != null;
-    }
-
-    if (this.permissions.has(perm)) {
-      return true;
-    }
-
-    if (includeOptional && this.manifest.optional_permissions.includes(perm)) {
-      return true;
-    }
-
-    return false;
-  }
-
   get name() {
     return this.manifest.name;
   }
 
   get optionalOrigins() {
     if (this._optionalOrigins == null) {
       let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm).origin);
-      this._optionalOrigins = new MatchPatternSet(origins, {ignorePath: true});
+      this._optionalOrigins = new MatchPatternSet(origins, {restrictSchemes: !this.hasPermission("mozillaAddons"), ignorePath: true});
     }
     return this._optionalOrigins;
   }
 }
 
 class Dictionary extends ExtensionData {
   constructor(addonData, startupReason) {
     super(addonData.resourceURI);
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -591,21 +591,24 @@ class BrowserExtensionContent extends Ev
     this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`;
     Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     defineLazyGetter(this, "scripts", () => {
       return data.contentScripts.map(scriptData => new ExtensionContent.Script(this, scriptData));
     });
 
     this.webAccessibleResources = data.webAccessibleResources.map(res => new MatchGlob(res));
-    this.whiteListedHosts = new MatchPatternSet(data.whiteListedHosts, {ignorePath: true});
     this.permissions = data.permissions;
     this.optionalPermissions = data.optionalPermissions;
     this.principal = data.principal;
 
+    let restrictSchemes = !this.hasPermission("mozillaAddons");
+
+    this.whiteListedHosts = new MatchPatternSet(data.whiteListedHosts, {restrictSchemes, ignorePath: true});
+
     this.apiManager = this.getAPIManager();
 
     this.localeData = new LocaleData(data.localeData);
 
     this.manifest = data.manifest;
     this.baseURL = data.baseURL;
     this.baseURI = Services.io.newURI(data.baseURL);
 
@@ -622,17 +625,17 @@ class BrowserExtensionContent extends Ev
           this.permissions.add(perm);
         }
       }
 
       if (permissions.origins.length > 0) {
         let patterns = this.whiteListedHosts.patterns.map(host => host.pattern);
 
         this.whiteListedHosts = new MatchPatternSet([...patterns, ...permissions.origins],
-                                                    {ignorePath: true});
+                                                    {restrictSchemes, ignorePath: true});
       }
 
       if (this.policy) {
         this.policy.permissions = Array.from(this.permissions);
         this.policy.allowedOrigins = this.whiteListedHosts;
       }
     });
 
--- a/toolkit/components/extensions/WebExtensionContentScript.h
+++ b/toolkit/components/extensions/WebExtensionContentScript.h
@@ -156,16 +156,17 @@ protected:
   WebExtensionContentScript(WebExtensionPolicy& aExtension,
                             const ContentScriptInit& aInit,
                             ErrorResult& aRv);
 
 private:
   RefPtr<WebExtensionPolicy> mExtension;
 
   bool mHasActiveTabPermission;
+  bool mRestricted;
 
   RefPtr<MatchPatternSet> mMatches;
   RefPtr<MatchPatternSet> mExcludeMatches;
 
   Nullable<MatchGlobSet> mIncludeGlobs;
   Nullable<MatchGlobSet> mExcludeGlobs;
 
   nsTArray<nsString> mCssPaths;
--- a/toolkit/components/extensions/WebExtensionPolicy.cpp
+++ b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -444,16 +444,17 @@ WebExtensionContentScript::Constructor(G
   return script.forget();
 }
 
 WebExtensionContentScript::WebExtensionContentScript(WebExtensionPolicy& aExtension,
                                                      const ContentScriptInit& aInit,
                                                      ErrorResult& aRv)
   : mExtension(&aExtension)
   , mHasActiveTabPermission(aInit.mHasActiveTabPermission)
+  , mRestricted(!aExtension.HasPermission(nsGkAtoms::mozillaAddons))
   , mMatches(aInit.mMatches)
   , mExcludeMatches(aInit.mExcludeMatches)
   , mCssPaths(aInit.mCssPaths)
   , mJsPaths(aInit.mJsPaths)
   , mRunAt(aInit.mRunAt)
   , mAllFrames(aInit.mAllFrames)
   , mFrameID(aInit.mFrameID)
   , mMatchAboutBlank(aInit.mMatchAboutBlank)
@@ -488,17 +489,17 @@ WebExtensionContentScript::Matches(const
   // matchAboutBlank is true and it has the null principal. In all other
   // cases, we test the URL of the principal that it inherits.
   if (mMatchAboutBlank && aDoc.IsTopLevel() &&
       aDoc.URL().Spec().EqualsLiteral("about:blank") &&
       aDoc.Principal() && aDoc.Principal()->GetIsNullPrincipal()) {
     return true;
   }
 
-  if (mExtension->IsRestrictedDoc(aDoc)) {
+  if (mRestricted && mExtension->IsRestrictedDoc(aDoc)) {
     return false;
   }
 
   auto& urlinfo = aDoc.PrincipalURL();
   if (mHasActiveTabPermission && aDoc.ShouldMatchActiveTabPermission() &&
       MatchPattern::MatchesAllURLs(urlinfo)) {
     return true;
   }
@@ -520,17 +521,17 @@ WebExtensionContentScript::MatchesURI(co
   if (!mIncludeGlobs.IsNull() && !mIncludeGlobs.Value().Matches(aURL.Spec())) {
     return false;
   }
 
   if (!mExcludeGlobs.IsNull() && mExcludeGlobs.Value().Matches(aURL.Spec())) {
     return false;
   }
 
-  if (mExtension->IsRestrictedURI(aURL)) {
+  if (mRestricted && mExtension->IsRestrictedURI(aURL)) {
     return false;
   }
 
   return true;
 }
 
 
 JSObject*
--- a/toolkit/components/extensions/extension-process-script.js
+++ b/toolkit/components/extensions/extension-process-script.js
@@ -30,26 +30,35 @@ const {
 } = ExtensionUtils;
 
 // We need to avoid touching Services.appinfo here in order to prevent
 // the wrong version from being cached during xpcshell test startup.
 // eslint-disable-next-line mozilla/use-services
 const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
 const isContentProcess = appinfo.processType == appinfo.PROCESS_TYPE_CONTENT;
 
-function parseScriptOptions(options) {
+function tryMatchPatternSet(patterns, options) {
+  try {
+    return new MatchPatternSet(patterns, options);
+  } catch (e) {
+    Cu.reportError(e);
+    return new MatchPatternSet([]);
+  }
+}
+
+function parseScriptOptions(options, restrictSchemes = true) {
   return {
     allFrames: options.all_frames,
     matchAboutBlank: options.match_about_blank,
     frameID: options.frame_id,
     runAt: options.run_at,
     hasActiveTabPermission: options.hasActiveTabPermission,
 
-    matches: new MatchPatternSet(options.matches),
-    excludeMatches: new MatchPatternSet(options.exclude_matches || []),
+    matches: tryMatchPatternSet(options.matches, {restrictSchemes}),
+    excludeMatches: tryMatchPatternSet(options.exclude_matches || [], {restrictSchemes}),
     includeGlobs: options.include_globs && options.include_globs.map(glob => new MatchGlob(glob)),
     excludeGlobs: options.exclude_globs && options.exclude_globs.map(glob => new MatchGlob(glob)),
 
     jsPaths: options.js || [],
     cssPaths: options.css || [],
   };
 }
 
@@ -129,17 +138,17 @@ class ExtensionGlobal {
     switch (messageName) {
       case "Extension:Capture":
         return ExtensionContent.handleExtensionCapture(this.global, data.width, data.height, data.options);
       case "Extension:DetectLanguage":
         return ExtensionContent.handleDetectLanguage(this.global, target);
       case "Extension:Execute":
         let policy = WebExtensionPolicy.getByID(recipient.extensionId);
 
-        let matcher = new WebExtensionContentScript(policy, parseScriptOptions(data.options));
+        let matcher = new WebExtensionContentScript(policy, parseScriptOptions(data.options, !policy.hasPermission("mozillaAddons")));
 
         Object.assign(matcher, {
           wantReturnValue: data.options.wantReturnValue,
           removeCSS: data.options.remove_css,
           cssOrigin: data.options.css_origin,
           jsCode: data.options.jsCode,
         });
 
@@ -319,16 +328,18 @@ ExtensionManager = {
         webAccessibleResources = extension.webAccessibleResources;
       } else {
         // We have serialized extension data;
         localizeCallback = str => extensions.get(policy).localize(str);
         allowedOrigins = new MatchPatternSet(extension.whiteListedHosts);
         webAccessibleResources = extension.webAccessibleResources.map(host => new MatchGlob(host));
       }
 
+      let restrictSchemes = !extension.permissions.has("mozillaAddons");
+
       policy = new WebExtensionPolicy({
         id: extension.id,
         mozExtensionHostname: extension.uuid,
         name: extension.name,
         baseURL: extension.resourceURL,
 
         permissions: Array.from(extension.permissions),
         allowedOrigins,
@@ -336,29 +347,29 @@ ExtensionManager = {
 
         contentSecurityPolicy: extension.manifest.content_security_policy,
 
         localizeCallback,
 
         backgroundScripts: (extension.manifest.background &&
                             extension.manifest.background.scripts),
 
-        contentScripts: extension.contentScripts.map(parseScriptOptions),
+        contentScripts: extension.contentScripts.map(script => parseScriptOptions(script, restrictSchemes)),
       });
 
       policy.debugName = `${JSON.stringify(policy.name)} (ID: ${policy.id}, ${policy.getURL()})`;
 
       // Register any existent dynamically registered content script for the extension
       // when a content process is started for the first time (which also cover
       // a content process that crashed and it has been recreated).
       const registeredContentScripts = this.registeredContentScripts.get(policy);
 
       if (extension.registeredContentScripts) {
         for (let [scriptId, options] of extension.registeredContentScripts) {
-          const parsedOptions = parseScriptOptions(options);
+          const parsedOptions = parseScriptOptions(options, restrictSchemes);
           const script = new WebExtensionContentScript(policy, parsedOptions);
           policy.registerContentScript(script);
           registeredContentScripts.set(scriptId, script);
         }
       }
 
       policy.active = true;
       policy.initData = extension;
@@ -426,17 +437,17 @@ ExtensionManager = {
         if (policy) {
           const registeredContentScripts = this.registeredContentScripts.get(policy);
 
           if (registeredContentScripts.has(data.scriptId)) {
             Cu.reportError(new Error(
               `Registering content script ${data.scriptId} on ${data.id} more than once`));
           } else {
             try {
-              const parsedOptions = parseScriptOptions(data.options);
+              const parsedOptions = parseScriptOptions(data.options, !policy.hasPermission("mozillaAddons"));
               const script = new WebExtensionContentScript(policy, parsedOptions);
               policy.registerContentScript(script);
               registeredContentScripts.set(data.scriptId, script);
             } catch (e) {
               Cu.reportError(e);
             }
           }
         }
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -448,16 +448,19 @@
         "id": "MatchPattern",
         "choices": [
           {
             "type": "string",
             "enum": ["<all_urls>"]
           },
           {
             "$ref": "MatchPatternRestricted"
+          },
+          {
+            "$ref": "MatchPatternUnestricted"
           }
         ]
       },
       {
         "id": "MatchPatternRestricted",
         "description": "Same as MatchPattern above, but excludes <all_urls>",
         "choices": [
           {
@@ -466,16 +469,26 @@
           },
           {
             "type": "string",
             "pattern": "^file:///.*$"
           }
         ]
       },
       {
+        "id": "MatchPatternUnestricted",
+        "description": "Mostly unrestricted match patterns for privileged add-ons. This should technically be rejected for unprivileged add-ons, but, reasons. The MatchPattern class will still refuse privileged schemes for those extensions.",
+        "choices": [
+          {
+            "type": "string",
+            "pattern": "^resource://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$|^about:"
+          }
+        ]
+      },
+      {
         "id": "MatchPatternInternal",
         "description": "Same as MatchPattern above, but includes moz-extension protocol",
         "choices": [
           {
             "type": "string",
             "enum": ["<all_urls>"]
           },
           {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
@@ -0,0 +1,54 @@
+"use strict";
+
+function makeExtension(id, isPrivileged) {
+  return ExtensionTestUtils.loadExtension({
+    isPrivileged,
+
+    manifest: {
+      applications: {gecko: {id}},
+
+      permissions: isPrivileged ? ["mozillaAddons"] : [],
+
+      content_scripts: [
+        {
+          "matches": ["resource://foo/file_sample.html"],
+          "js": ["content_script.js"],
+          "run_at": "document_start",
+        },
+      ],
+    },
+
+    files: {
+      "content_script.js"() {
+        browser.test.assertEq("resource://foo/file_sample.html", document.documentURI,
+                              `Loaded content script into the correct document (extension: ${browser.runtime.id})`);
+        browser.test.sendMessage(`content-script-${browser.runtime.id}`);
+      },
+    },
+  });
+}
+
+add_task(async function test_contentscript_restrictSchemes() {
+  let resProto = Services.io.getProtocolHandler("resource").QueryInterface(Ci.nsIResProtocolHandler);
+  resProto.setSubstitutionWithFlags("foo", Services.io.newFileURI(do_get_file("data")),
+                                    resProto.ALLOW_CONTENT_ACCESS);
+
+  let unprivileged = makeExtension("unprivileged@tests.mozilla.org", false);
+  let privileged = makeExtension("privileged@tests.mozilla.org", true);
+
+  await unprivileged.startup();
+  await privileged.startup();
+
+  unprivileged.onMessage("content-script-unprivileged@tests.mozilla.org", () => {
+    ok(false, "Unprivileged extension executed content script on resource URL");
+  });
+
+  let contentPage = await ExtensionTestUtils.loadContentPage(`resource://foo/file_sample.html`);
+
+  await privileged.awaitMessage("content-script-privileged@tests.mozilla.org");
+
+  await contentPage.close();
+
+  await privileged.unload();
+  await unprivileged.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -22,16 +22,17 @@ skip-if = os == "android"
 [test_ext_content_security_policy.js]
 [test_ext_contentscript_api_injection.js]
 [test_ext_contentscript_async_loading.js]
 skip-if = os == 'android' && debug # The generated script takes too long to load on Android debug
 [test_ext_contentscript_context.js]
 [test_ext_contentscript_create_iframe.js]
 [test_ext_contentscript_css.js]
 [test_ext_contentscript_exporthelpers.js]
+[test_ext_contentscript_restrictSchemes.js]
 [test_ext_contentscript_teardown.js]
 [test_ext_contextual_identities.js]
 skip-if = os == "android" # Containers are not exposed to android.
 [test_ext_debugging_utils.js]
 [test_ext_dns.js]
 [test_ext_downloads.js]
 [test_ext_downloads_download.js]
 skip-if = os == "android"
--- a/xpcom/ds/nsGkAtomList.h
+++ b/xpcom/ds/nsGkAtomList.h
@@ -1807,16 +1807,17 @@ GK_ATOM(ondevicelight, "ondevicelight")
 GK_ATOM(ondevicechange, "ondevicechange")
 
 // WebExtensions
 GK_ATOM(moz_extension, "moz-extension")
 GK_ATOM(all_urlsPermission, "<all_urls>")
 GK_ATOM(clipboardRead, "clipboardRead")
 GK_ATOM(clipboardWrite, "clipboardWrite")
 GK_ATOM(debugger, "debugger")
+GK_ATOM(mozillaAddons, "mozillaAddons")
 GK_ATOM(tabs, "tabs")
 GK_ATOM(webRequestBlocking, "webRequestBlocking")
 GK_ATOM(http, "http")
 GK_ATOM(https, "https")
 GK_ATOM(proxy, "proxy")
 
 //---------------------------------------------------------------------------
 // Special atoms