Bug 1225715: Part 5 - Add schema for extension manifests. r=billm

This currently forbids unknown top-level schema properties, and unknown
permissions. In the future, I'd like to make those warnings rather than
errors, for compatibility purposes, but I think errors are fine for now.

--HG--
extra : commitid : 9jGEwCU9AhR
extra : rebase_source : db16f1e5f9962fb7b24c0e52c05832ae646a57c2
This commit is contained in:
Kris Maglione 2016-01-30 10:27:02 -08:00
parent 7223a1a63e
commit 278a332b02
22 changed files with 554 additions and 138 deletions

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"bookmarks"
]
}]
}
]
},
{
"namespace": "bookmarks",
"description": "Use the <code>browser.bookmarks</code> API to create, organize, and otherwise manipulate bookmarks. Also see $(topic:override)[Override Pages], which you can use to create a custom Bookmark Manager page.",

View File

@ -3,6 +3,25 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "WebExtensionManifest",
"properties": {
"browser_action": {
"type": "object",
"properties": {
"default_title": { "type": "string", "optional": true },
"default_icon": { "$ref": "IconPath", "optional": true },
"default_popup": { "type": "string", "format": "relativeUrl", "optional": true }
},
"optional": true
}
}
}
]
},
{
"namespace": "browserAction",
"description": "Use browser actions to put icons in the main browser toolbar, to the right of the address bar. In addition to its icon, a browser action can also have a tooltip, a badge, and a popup.",

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"contextMenus"
]
}]
}
]
},
{
"namespace": "contextMenus",
"description": "Use the <code>browser.contextMenus</code> API to add items to the browser's context menu. You can choose what types of objects your context menu additions apply to, such as images, hyperlinks, and pages.",

View File

@ -3,6 +3,25 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "WebExtensionManifest",
"properties": {
"page_action": {
"type": "object",
"properties": {
"default_title": { "type": "string", "optional": true },
"default_icon": { "$ref": "IconPath", "optional": true },
"default_popup": { "type": "string", "format": "relativeUrl", "optional": true }
},
"optional": true
}
}
}
]
},
{
"namespace": "pageAction",
"description": "Use the <code>browser.pageAction</code> API to put icons inside the address bar. Page actions represent actions that can be taken on the current page, but that aren't applicable to all pages.",

View File

@ -3,6 +3,21 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"activeTab",
"tabs"
]
}]
}
]
},
{
"namespace": "tabs",
"description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.",

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"windows"
]
}]
}
]
},
{
"namespace": "windows",
"description": "Use the <code>browser.windows</code> API to interact with browser windows. You can use this API to create, modify, and rearrange windows in the browser.",

View File

@ -374,50 +374,49 @@ add_task(function* testSecureURLsDenied() {
yield extension.awaitFinish("setIcon security tests");
yield extension.unload();
});
add_task(function* testSecureManifestURLsDenied() {
// Test URLs included in the manifest.
let urls = ["chrome://browser/content/browser.xul",
"javascript:true"];
let matchURLForbidden = url => ({
message: new RegExp(`Loading extension.*Invalid icon data: NS_ERROR_DOM_BAD_URI`),
});
let apis = ["browser_action", "page_action"];
// Because the underlying method throws an error on invalid data,
// only the first invalid URL of each component will be logged.
let messages = [matchURLForbidden(urls[0]),
matchURLForbidden(urls[1])];
for (let url of urls) {
for (let api of apis) {
info(`TEST ${api} icon url: ${url}`);
let waitForConsole = new Promise(resolve => {
// Not necessary in browser-chrome tests, but monitorConsole gripes
// if we don't call it.
SimpleTest.waitForExplicitFinish();
let matchURLForbidden = url => ({
message: new RegExp(`String "${url}" must be a relative URL`),
});
SimpleTest.monitorConsole(resolve, messages);
});
let messages = [matchURLForbidden(url)];
extension = ExtensionTestUtils.loadExtension({
manifest: {
"browser_action": {
"default_icon": {
"19": urls[0],
"38": urls[1],
let waitForConsole = new Promise(resolve => {
// Not necessary in browser-chrome tests, but monitorConsole gripes
// if we don't call it.
SimpleTest.waitForExplicitFinish();
SimpleTest.monitorConsole(resolve, messages);
});
let extension = ExtensionTestUtils.loadExtension({
manifest: {
[api]: {
"default_icon": url,
},
},
},
"page_action": {
"default_icon": {
"19": urls[1],
"38": urls[0],
},
},
},
});
});
yield extension.startup();
yield extension.unload();
yield Assert.rejects(extension.startup(),
null,
"Manifest rejected");
SimpleTest.endMonitorConsole();
yield waitForConsole;
SimpleTest.endMonitorConsole();
yield waitForConsole;
}
}
});

View File

@ -148,47 +148,34 @@ add_task(function* testPageActionPopup() {
add_task(function* testPageActionSecurity() {
const URL = "chrome://browser/content/browser.xul";
let messages = [/Access to restricted URI denied/,
/Access to restricted URI denied/];
let apis = ["browser_action", "page_action"];
let waitForConsole = new Promise(resolve => {
// Not necessary in browser-chrome tests, but monitorConsole gripes
// if we don't call it.
SimpleTest.waitForExplicitFinish();
for (let api of apis) {
info(`TEST ${api} icon url: ${URL}`);
SimpleTest.monitorConsole(resolve, messages);
});
let messages = [/Access to restricted URI denied/];
let extension = ExtensionTestUtils.loadExtension({
manifest: {
"browser_action": { "default_popup": URL },
"page_action": { "default_popup": URL },
},
let waitForConsole = new Promise(resolve => {
// Not necessary in browser-chrome tests, but monitorConsole gripes
// if we don't call it.
SimpleTest.waitForExplicitFinish();
background: function() {
browser.tabs.query({ active: true, currentWindow: true }, tabs => {
let tabId = tabs[0].id;
SimpleTest.monitorConsole(resolve, messages);
});
browser.pageAction.show(tabId);
browser.test.sendMessage("ready");
});
},
});
let extension = ExtensionTestUtils.loadExtension({
manifest: {
[api]: { "default_popup": URL },
},
});
yield extension.startup();
yield extension.awaitMessage("ready");
yield Assert.rejects(extension.startup(),
null,
"Manifest rejected");
yield clickBrowserAction(extension);
yield clickPageAction(extension);
yield extension.unload();
let pageActionId = makeWidgetId(extension.id) + "-page-action";
let node = document.getElementById(pageActionId);
is(node, null, "pageAction image removed from document");
SimpleTest.endMonitorConsole();
yield waitForConsole;
SimpleTest.endMonitorConsole();
yield waitForConsole;
}
});
add_task(forceGC);

View File

@ -1889,6 +1889,10 @@ SpecialPowersAPI.prototype = {
});
let unloadPromise = new Promise(resolve => { resolveUnload = resolve; });
startupPromise.catch(() => {
this._removeMessageListener("SPExtensionMessage", listener);
});
handler = Cu.waiveXrays(handler);
ext = Cu.waiveXrays(ext);

View File

@ -70,6 +70,8 @@ ExtensionManagement.registerScript("chrome://extensions/content/ext-webRequest.j
ExtensionManagement.registerScript("chrome://extensions/content/ext-storage.js");
ExtensionManagement.registerScript("chrome://extensions/content/ext-test.js");
const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
ExtensionManagement.registerSchema("chrome://extensions/content/schemas/cookies.json");
ExtensionManagement.registerSchema("chrome://extensions/content/schemas/extension.json");
ExtensionManagement.registerSchema("chrome://extensions/content/schemas/extension_types.json");
@ -111,10 +113,15 @@ var Management = {
return this.initialized;
}
let promises = [];
for (let schema of ExtensionManagement.getSchemas()) {
promises.push(Schemas.load(schema));
}
// Load order matters here. The base manifest defines types which are
// extended by other schemas, so needs to be loaded first.
let promise = Schemas.load(BASE_SCHEMA).then(() => {
let promises = [];
for (let schema of ExtensionManagement.getSchemas()) {
promises.push(Schemas.load(schema));
}
return Promise.all(promises);
});
for (let script of ExtensionManagement.getScripts()) {
let scope = {extensions: this,
@ -127,7 +134,7 @@ var Management = {
this.scopes.push(scope);
}
this.initialized = Promise.all(promises);
this.initialized = promise;
return this.initialized;
},
@ -551,20 +558,31 @@ ExtensionData.prototype = {
// Reads the extension's |manifest.json| file, and stores its
// parsed contents in |this.manifest|.
readManifest() {
return this.readJSON("manifest.json").then(manifest => {
this.manifest = manifest;
return Promise.all([
this.readJSON("manifest.json"),
Management.lazyInit(),
]).then(([manifest]) => {
let context = {
url: (this.baseURI || this.rootURI).spec,
principal: this.principal,
};
let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
if (normalized.error) {
this.manifestError(normalized.error);
this.manifest = manifest;
} else {
this.manifest = normalized.value;
}
try {
this.id = this.manifest.applications.gecko.id;
} catch (e) {
// Errors are handled by the type check below.
// Errors are handled by the type checks above.
}
if (typeof this.id != "string") {
this.manifestError("Missing required `applications.gecko.id` property");
}
return manifest;
return this.manifest;
});
},
@ -579,7 +597,7 @@ ExtensionData.prototype = {
// If a "default_locale" is specified in that manifest, returns it
// as a Gecko-compatible locale string. Otherwise, returns null.
get defaultLocale() {
if ("default_locale" in this.manifest) {
if (this.manifest.default_locale != null) {
return this.normalizeLocaleCode(this.manifest.default_locale);
}
@ -975,7 +993,9 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
this.webAccessibleResources = resources;
for (let directive in manifest) {
Management.emit("manifest_" + directive, directive, this, manifest);
if (manifest[directive] !== null) {
Management.emit("manifest_" + directive, directive, this, manifest);
}
}
let data = Services.ppmm.initialProcessData;
@ -1027,13 +1047,19 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
return Promise.reject(e);
}
let lazyInit = Management.lazyInit();
return this.readManifest().then(() => {
if (!this.hasShutdown) {
return this.initLocale();
}
}).then(() => {
if (this.errors.length) {
// b2g add-ons generate manifest errors that we've silently
// ignoring prior to adding this check.
if (!this.rootURI.schemeIs("app")) {
return Promise.reject({errors: this.errors});
}
}
return lazyInit.then(() => {
return this.readManifest();
}).then(() => {
return this.initLocale();
}).then(() => {
if (this.hasShutdown) {
return;
}
@ -1046,6 +1072,11 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
}).catch(e => {
dump(`Extension error: ${e} ${e.filename || e.fileName}:${e.lineNumber}\n`);
Cu.reportError(e);
ExtensionManagement.shutdownExtension(this.uuid);
this.cleanupGeneratedFile();
throw e;
});
},
@ -1072,6 +1103,9 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
shutdown() {
this.hasShutdown = true;
if (!this.manifest) {
ExtensionManagement.shutdownExtension(this.uuid);
this.cleanupGeneratedFile();
return;
}
@ -1093,7 +1127,6 @@ Extension.prototype = extend(Object.create(ExtensionData.prototype), {
ExtensionManagement.shutdownExtension(this.uuid);
// Clean up a generated file.
this.cleanupGeneratedFile();
},

View File

@ -9,6 +9,8 @@ const Cc = Components.classes;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
var {
instanceOf,
@ -147,14 +149,18 @@ const FORMATS = {
url(string, context) {
let url = new URL(string).href;
context.checkLoadURL(url);
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
relativeUrl(string, context) {
let url = new URL(string, context.url).href;
context.checkLoadURL(url);
if (!context.checkLoadURL(url)) {
throw new Error(`Access denied for URL ${url}`);
}
return url;
},
@ -945,7 +951,7 @@ this.Schemas = {
}
let additionalProperties = null;
if ("additionalProperties" in type) {
if (type.additionalProperties) {
additionalProperties = this.parseType(namespaceName, type.additionalProperties);
}
@ -1106,6 +1112,18 @@ this.Schemas = {
for (let [name, entry] of ns) {
entry.inject(name, obj, new Context(wrapperFuncs));
}
if (!Object.keys(obj).length) {
delete dest[namespace];
}
}
},
normalize(obj, typeName, context) {
let [namespaceName, prop] = typeName.split(".");
let ns = this.namespaces.get(namespaceName);
let type = ns.get(prop);
return type.normalize(obj, new Context(context));
},
};

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"cookies"
]
}]
}
]
},
{
"namespace": "cookies",
"description": "Use the <code>browser.cookies</code> API to query and modify cookies, and to be notified when they change.",

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "WebExtensionManifest",
"properties": {
"default_locale": {
"type": "string",
"optional": "true"
}
}
}
]
},
{
"namespace": "i18n",
"description": "Use the <code>browser.i18n</code> infrastructure to implement internationalization across your whole app or extension.",

View File

@ -9,6 +9,7 @@ toolkit.jar:
content/extensions/schemas/extension_types.json
content/extensions/schemas/i18n.json
content/extensions/schemas/idle.json
content/extensions/schemas/manifest.json
content/extensions/schemas/runtime.json
content/extensions/schemas/web_navigation.json
content/extensions/schemas/web_request.json

View File

@ -0,0 +1,229 @@
[
{
"namespace": "manifest",
"types": [
{
"id": "WebExtensionManifest",
"type": "object",
"description": "Represents a WebExtension manifest.json file",
"properties": {
"manifest_version": {
"type": "integer",
"minimum": 2,
"maximum": 2
},
"applications": {
"type": "object",
"properties": {
"gecko": {
"type": "object",
"properties": {
"id": { "$ref": "ExtensionID" },
"update_url": {
"type": "string",
"format": "url",
"optional": true
},
"strict_min_version": {
"type": "string",
"optional": true
},
"strict_max_version": {
"type": "string",
"optional": true
}
}
}
}
},
"name": {
"type": "string",
"optional": false
},
"description": {
"type": "string",
"optional": true
},
"version": {
"type": "string",
"optional": false
},
"icons": {
"type": "object",
"optional": true,
"patternProperties": {
"^[1-9]\\d*$": { "type": "string" }
}
},
"background": {
"choices": [
{
"type": "object",
"properties": {
"page": { "$ref": "ExtensionURL" }
}
},
{
"type": "object",
"properties": {
"scripts": {
"type": "array",
"items": { "$ref": "ExtensionURL" }
}
}
}
],
"optional": true
},
"content_scripts": {
"type": "array",
"optional": true,
"items": { "$ref": "ContentScript" }
},
"permissions": {
"type": "array",
"items": { "$ref": "Permission" },
"optional": true
},
"web_accessible_resources": {
"type": "array",
"items": { "type": "string" },
"optional": true
}
}
},
{
"id": "Permission",
"choices": [
{
"type": "string",
"enum": [
"alarms",
"idle",
"notifications",
"storage"
]
},
{ "$ref": "MatchPattern" }
]
},
{
"id": "ExtensionURL",
"type": "string",
"format": "strictRelativeUrl"
},
{
"id": "ExtensionID",
"choices": [
{
"type": "string",
"pattern": "(?i)^\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\}$"
},
{
"type": "string",
"pattern": "(?i)^[a-z0-9-._]*@[a-z0-9-._]+$"
}
]
},
{
"id": "MatchPattern",
"choices": [
{
"type": "string",
"enum": ["<all_urls>"]
},
{
"type": "string",
"pattern": "^(https?|file|ftp|app|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$"
},
{
"type": "string",
"pattern": "^file:///.*$"
}
]
},
{
"id": "ContentScript",
"type": "object",
"description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time. Based on InjectDetails, but using underscore rather than camel case naming conventions.",
"properties": {
"matches": {
"type": "array",
"optional": false,
"minItems": 1,
"items": { "$ref": "MatchPattern" }
},
"exclude_matches": {
"type": "array",
"optional": true,
"minItems": 1,
"items": { "$ref": "MatchPattern" }
},
"css": {
"type": "array",
"optional": true,
"description": "The list of CSS files to inject",
"items": { "$ref": "ExtensionURL" }
},
"js": {
"type": "array",
"optional": true,
"description": "The list of CSS files to inject",
"items": { "$ref": "ExtensionURL" }
},
"all_frames": {"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."},
"match_about_blank": {"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>."},
"run_at": {
"$ref": "extensionTypes.RunAt",
"optional": true,
"description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
}
}
},
{
"id": "IconPath",
"choices": [
{
"type": "object",
"patternProperties": {
"^[1-9]\\d*$": { "$ref": "ExtensionURL" }
},
"additionalProperties": false
},
{ "$ref": "ExtensionURL" }
]
},
{
"id": "IconImageData",
"choices": [
{
"type": "object",
"patternProperties": {
"^[1-9]\\d*$": {
"type": "object",
"isInstanceOf": "ImageData"
}
},
"additionalProperties": false
},
{
"type": "object",
"isInstanceOf": "ImageData"
}
]
}
]
}
]

View File

@ -3,6 +3,20 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"webNavigation"
]
}]
}
]
},
{
"namespace": "webNavigation",
"description": "Use the <code>browser.webNavigation</code> API to receive notifications about the status of navigation requests in-flight.",

View File

@ -3,6 +3,21 @@
// found in the LICENSE file.
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [{
"type": "string",
"enum": [
"webRequest",
"webRequestBlocking"
]
}]
}
]
},
{
"namespace": "webRequest",
"description": "Use the <code>browser.webRequest</code> API to observe and analyze traffic and to intercept, block, or modify requests in-flight.",

View File

@ -24,6 +24,7 @@ support-files =
file_permission_xhr.html
[test_ext_simple.html]
[test_ext_schema.html]
[test_ext_geturl.html]
[test_ext_contentscript.html]
skip-if = buildapp == 'b2g' # runat != document_idle is not supported.

View File

@ -0,0 +1,36 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Test for schema API creation</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
<script type="text/javascript" src="head.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
</head>
<body>
<script type="text/javascript">
"use strict";
add_task(function* testSchema() {
function background() {
browser.test.assertTrue(!("manifest" in browser), "browser.manifest is not defined");
browser.test.notifyPass("schema");
}
let extension = ExtensionTestUtils.loadExtension({
background: `(${background})()`,
});
yield extension.startup();
yield extension.awaitFinish("schema");
yield extension.unload();
});
</script>
</body>
</html>

View File

@ -279,10 +279,7 @@ let wrapper = {
url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
checkLoadURL(url) {
if (url.startsWith("chrome:")) {
throw new Error("Access denied");
}
return url;
return !url.startsWith("chrome:");
},
callFunction(ns, name, args) {

View File

@ -135,7 +135,7 @@ add_task(function* checkUpdateMetadata() {
addon: {
manifest: {
version: "1.0",
application: { gecko: { strict_max_version: "45" } },
applications: { gecko: { strict_max_version: "45" } },
}
},
updates: {
@ -240,7 +240,7 @@ add_task(function* checkIllegalUpdateURL() {
});
});
ok(messages.some(msg => /nsIScriptSecurityManager.checkLoadURIStrWithPrincipal/.test(msg)),
ok(messages.some(msg => /Access denied for URL|may not load or link to|is not a valid URL/.test(msg)),
"Got checkLoadURI error");
}
});

View File

@ -73,47 +73,6 @@ add_task(function*() {
yield promiseRestartManager();
});
// Test filtering invalid icon sizes
add_task(function*() {
writeWebManifestForExtension({
name: "Web Extension Name",
version: "1.0",
manifest_version: 2,
applications: {
gecko: {
id: ID
}
},
icons: {
32: "icon32.png",
banana: "bananana.png",
"20.5": "icon20.5.png",
"20.0": "also invalid",
"123banana": "123banana.png",
64: "icon64.png"
}
}, profileDir);
yield promiseRestartManager();
let addon = yield promiseAddonByID(ID);
do_check_neq(addon, null);
let uri = do_get_addon_root_uri(profileDir, ID);
deepEqual(addon.icons, {
32: uri + "icon32.png",
64: uri + "icon64.png"
});
equal(addon.iconURL, uri + "icon64.png");
equal(addon.icon64URL, uri + "icon64.png");
addon.uninstall();
yield promiseRestartManager();
});
// Test AddonManager.getPreferredIconURL for retina screen sizes
add_task(function*() {
writeWebManifestForExtension({