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