Bug 1456485: Part 2 - Allow extensions with the mozillaAddons permission to match restricted schemes. r=zombie
authorKris Maglione <maglione.k@gmail.com>
Wed, 09 May 2018 18:55:59 -0700
changeset 419410 364fa52d097e335146f94cbc98238c77b7789d61
parent 419409 8c259ed0e0d1cecf8fdc6092b8714e880e3169d5
child 419411 a57842c1d43846ff12687c85ec4138e41b5d2c60
push id103528
push usermaglione.k@gmail.com
push dateTue, 22 May 2018 22:59:41 +0000
treeherdermozilla-inbound@364fa52d097e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie
bugs1456485
milestone62.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
@@ -1809,16 +1809,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