Bug 1711168 allow extension pages to be loaded as top level tabs by other extensions r=rpl

Ensure extensions can manage tabs and sessions that include tabs from other extensions.  The parent
patch to this introduces cross-extension access validation.  However that breaks this specific use case
that we need to continue supporting.  This patch modifies three extension apis, tab.create/update and
windows.create to allow the creation of extension tabs which cannot be otherwise accessed.

Differential Revision: https://phabricator.services.mozilla.com/D151766
This commit is contained in:
Shane Caraveo
2022-07-26 19:39:14 +00:00
parent addcb41d12
commit 26d3eac02e
7 changed files with 535 additions and 146 deletions

View File

@@ -611,6 +611,24 @@ this.tabs = class extends ExtensionAPIPersistent {
return tab;
}
function setContentTriggeringPrincipal(url, browser, options) {
// For urls that we want to allow an extension to open in a tab, but
// that it may not otherwise have access to, we set the triggering
// principal to the url that is being opened. This is used for newtab,
// about: and moz-extension: protocols.
// We also prevent discarded, or lazy tabs by setting allowInheritPrincipal to false.
options.allowInheritPrincipal = false;
options.triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(url),
{
userContextId: options.userContextId,
privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(browser)
? 1
: 0,
}
);
}
let tabsApi = {
tabs: {
onActivated: new EventManager({
@@ -706,9 +724,11 @@ this.tabs = class extends ExtensionAPIPersistent {
}
}).then(window => {
let url;
let principal = context.principal;
let options = {};
let options = {
allowInheritPrincipal: true,
triggeringPrincipal: context.principal,
};
if (createProperties.cookieStoreId) {
// May throw if validation fails.
options.userContextId = getUserContextIdForCookieStoreId(
@@ -721,7 +741,10 @@ this.tabs = class extends ExtensionAPIPersistent {
if (createProperties.url !== null) {
url = context.uri.resolve(createProperties.url);
if (!context.checkLoadURL(url, { dontReportErrors: true })) {
if (
!url.startsWith("moz-extension://") &&
!context.checkLoadURL(url, { dontReportErrors: true })
) {
return Promise.reject({ message: `Illegal URL: ${url}` });
}
@@ -731,30 +754,10 @@ this.tabs = class extends ExtensionAPIPersistent {
} else {
url = window.BROWSER_NEW_TAB_URL;
}
// Only set allowInheritPrincipal on discardable urls as it
// will override creating a lazy browser. Setting triggeringPrincipal
// will ensure other cases are handled, but setting it may prevent
// creating about and data urls.
let discardable = url && !url.startsWith("about:");
if (!discardable) {
// Make sure things like about:blank and data: URIs never inherit,
// and instead always get a NullPrincipal.
options.allowInheritPrincipal = false;
// Falling back to content here as about: requires it, however is safe.
principal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(url),
{
userContextId: options.userContextId,
privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(
window.gBrowser
)
? 1
: 0,
}
);
} else {
options.allowInheritPrincipal = true;
options.triggeringPrincipal = context.principal;
// Handle moz-ext separately from the discardable flag to retain prior behavior.
if (!discardable || url?.startsWith("moz-extension://")) {
setContentTriggeringPrincipal(url, window.gBrowser, options);
}
tabListener.initTabReady();
@@ -814,7 +817,6 @@ this.tabs = class extends ExtensionAPIPersistent {
});
}
options.triggeringPrincipal = principal;
let nativeTab = window.gBrowser.addTab(url, options);
if (active) {
@@ -888,10 +890,6 @@ this.tabs = class extends ExtensionAPIPersistent {
if (updateProperties.url !== null) {
let url = context.uri.resolve(updateProperties.url);
if (!context.checkLoadURL(url, { dontReportErrors: true })) {
return Promise.reject({ message: `Illegal URL: ${url}` });
}
let options = {
flags: updateProperties.loadReplace
? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
@@ -899,6 +897,15 @@ this.tabs = class extends ExtensionAPIPersistent {
triggeringPrincipal: context.principal,
};
if (!context.checkLoadURL(url, { dontReportErrors: true })) {
// We allow loading top level tabs for "other" extensions.
if (url.startsWith("moz-extension://")) {
setContentTriggeringPrincipal(url, tabbrowser, options);
} else {
return Promise.reject({ message: `Illegal URL: ${url}` });
}
}
let browser = nativeTab.linkedBrowser;
if (nativeTab.linkedPanel) {
browser.loadURI(url, options);

View File

@@ -87,6 +87,26 @@ this.windows = class extends ExtensionAPIPersistent {
const { windowManager } = extension;
function getTriggeringPrincipalForUrl(url) {
if (context.checkLoadURL(url, { dontReportErrors: true })) {
return context.principal;
}
let window = context.currentWindow || windowTracker.topWindow;
// The extension principal cannot directly load about:-URLs except for about:blank, and
// possibly some other loads such as moz-extension. Ensure any page set as a home page
// will load by using a content principal.
return Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(url),
{
privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(
window.gBrowser
)
? 1
: 0,
}
);
}
return {
windows: {
onCreated: new EventManager({
@@ -182,7 +202,12 @@ this.windows = class extends ExtensionAPIPersistent {
Ci.nsIMutableArray
);
let principal = context.principal;
// Creating a new window allows one single triggering principal for all tabs that
// are created in the window. Due to that, if we need a browser principal to load
// some urls, we fallback to using a content principal like we do in the tabs api.
// Throws if url is an array and any url can't be loaded by the extension principal.
let { allowScriptsToClose, principal } = createData;
if (createData.tabId !== null) {
if (createData.url !== null) {
throw new ExtensionError(
@@ -231,12 +256,24 @@ this.windows = class extends ExtensionAPIPersistent {
let array = Cc["@mozilla.org/array;1"].createInstance(
Ci.nsIMutableArray
);
for (let url of createData.url) {
for (let url of createData.url.map(u => context.uri.resolve(u))) {
// We can only provide a single triggering principal when
// opening a window, so if the extension cannot normally
// access a url, we fail. This includes about and moz-ext
// urls.
if (!context.checkLoadURL(url, { dontReportErrors: true })) {
return Promise.reject({ message: `Illegal URL: ${url}` });
}
array.appendElement(mkstr(url));
}
args.appendElement(array);
} else {
args.appendElement(mkstr(createData.url));
let url = context.uri.resolve(createData.url);
args.appendElement(mkstr(url));
principal = getTriggeringPrincipalForUrl(url);
if (allowScriptsToClose === null) {
allowScriptsToClose = url.startsWith("moz-extension://");
}
}
} else {
let url =
@@ -245,15 +282,7 @@ this.windows = class extends ExtensionAPIPersistent {
? "about:privatebrowsing"
: HomePage.get().split("|", 1)[0];
args.appendElement(mkstr(url));
if (
url.startsWith("about:") &&
!context.checkLoadURL(url, { dontReportErrors: true })
) {
// The extension principal cannot directly load about:-URLs,
// except for about:blank. So use the system principal instead.
principal = Services.scriptSecurityManager.getSystemPrincipal();
}
principal = getTriggeringPrincipalForUrl(url);
}
args.appendElement(null); // extraOptions
@@ -271,6 +300,7 @@ this.windows = class extends ExtensionAPIPersistent {
createData.cookieStoreId,
createData.incognito
);
args.appendElement(userContextIdSupports); // userContextId
} else {
args.appendElement(null);
@@ -316,12 +346,6 @@ this.windows = class extends ExtensionAPIPersistent {
}
}
let { allowScriptsToClose, url } = createData;
if (allowScriptsToClose === null) {
allowScriptsToClose =
typeof url === "string" && url.startsWith("moz-extension://");
}
let window = Services.ww.openWindow(
null,
AppConstants.BROWSER_CHROME_URL,

View File

@@ -252,10 +252,10 @@
"description": "A URL or array of URLs to open as tabs in the window. Fully-qualified URLs must include a scheme (i.e. 'http://www.google.com', not 'www.google.com'). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page.",
"optional": true,
"choices": [
{ "type": "string", "format": "relativeUrl" },
{ "type": "string" },
{
"type": "array",
"items": { "type": "string", "format": "relativeUrl" }
"items": { "type": "string" }
}
]
},

View File

@@ -3,6 +3,8 @@
"use strict";
requestLongerTimeout(4);
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
@@ -717,3 +719,133 @@ add_task(async function test_overriding_home_page_incognito_external() {
await extension.unload();
});
// This tests that the homepage provided by an extension can be opened by any extension
// and does not require web_accessible_resource entries.
async function _test_overriding_home_page_open(manifest_version) {
let extension = ExtensionTestUtils.loadExtension({
manifest: {
manifest_version,
chrome_settings_overrides: { homepage: "home.html" },
name: "homepage provider",
applications: {
gecko: { id: "homepage@mochitest" },
},
},
files: {
"home.html": `<h1>Home Page!</h1><pre id="result"></pre><script src="home.js"></script>`,
"home.js": () => {
document.querySelector("#result").textContent = "homepage loaded";
},
},
useAddonManager: "permanent",
});
await extension.startup();
// ensure it works and deal with initial panel prompt.
await testHomePageWindow({
expectPanel: true,
async test(win) {
Assert.equal(
HomePage.get(win),
`moz-extension://${extension.uuid}/home.html`,
"The homepage is set"
);
Assert.equal(
win.gURLBar.value,
`moz-extension://${extension.uuid}/home.html`,
"extension is control in window"
);
const { selectedBrowser } = win.gBrowser;
const result = await SpecialPowers.spawn(
selectedBrowser,
[],
async () => {
const { document } = this.content;
if (document.readyState !== "complete") {
await new Promise(resolve => (document.onload = resolve));
}
return document.querySelector("#result").textContent;
}
);
Assert.equal(
result,
"homepage loaded",
"Overridden homepage loaded successfully"
);
},
});
// Extension used to open the homepage in a new window.
let opener = ExtensionTestUtils.loadExtension({
manifest: {
permissions: ["tabs"],
},
async background() {
let win;
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (tab.windowId !== win.id || tab.status !== "complete") {
return;
}
browser.test.sendMessage("created", tab.url);
});
browser.test.onMessage.addListener(async msg => {
if (msg == "create") {
win = await browser.windows.create({});
browser.test.assertTrue(
win.id !== browser.windows.WINDOW_ID_NONE,
"New window was created."
);
}
});
},
});
function listener(msg) {
Assert.ok(!/may not load or link to moz-extension/.test(msg.message));
}
Services.console.registerListener(listener);
registerCleanupFunction(() => {
Services.console.unregisterListener(listener);
});
await opener.startup();
const promiseNewWindow = BrowserTestUtils.waitForNewWindow();
await opener.sendMessage("create");
let homepageUrl = await opener.awaitMessage("created");
Assert.equal(
homepageUrl,
`moz-extension://${extension.uuid}/home.html`,
"The homepage is set"
);
const newWin = await promiseNewWindow;
Assert.equal(
await SpecialPowers.spawn(newWin.gBrowser.selectedBrowser, [], async () => {
const { document } = this.content;
if (document.readyState !== "complete") {
await new Promise(resolve => (document.onload = resolve));
}
return document.querySelector("#result").textContent;
}),
"homepage loaded",
"Overridden homepage loaded as expected"
);
await BrowserTestUtils.closeWindow(newWin);
await opener.unload();
await extension.unload();
}
add_task(async function test_overriding_home_page_open_mv2() {
await _test_overriding_home_page_open(2);
});
add_task(async function test_overriding_home_page_open_mv3() {
await SpecialPowers.pushPrefEnv({
set: [["extensions.manifestV3.enabled", true]],
});
await _test_overriding_home_page_open(3);
});

View File

@@ -3,6 +3,8 @@
"use strict";
requestLongerTimeout(4);
ChromeUtils.defineModuleGetter(
this,
"ExtensionSettingsStore",
@@ -713,3 +715,72 @@ add_task(async function testNewTabPrefsReset() {
"privateAllowed pref is not set"
);
});
// This test ensures that an extension provided newtab
// can be opened by another extension (e.g. tab manager)
// regardless of whether the newtab url is made available
// in web_accessible_resources.
add_task(async function test_newtab_from_extension() {
let panel = getNewTabDoorhanger().closest("panel");
let extension = ExtensionTestUtils.loadExtension({
manifest: {
applications: {
gecko: {
id: "newtaburl@mochi.test",
},
},
chrome_url_overrides: {
newtab: "newtab.html",
},
},
files: {
"newtab.html": `<h1>New tab!</h1><script src="newtab.js"></script>`,
"newtab.js": () => {
browser.test.sendMessage("newtab-loaded");
},
},
useAddonManager: "temporary",
});
await extension.startup();
let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`;
let popupShown = promisePopupShown(panel);
let tab = await promiseNewTab(extensionNewTabUrl);
await popupShown;
// This will show a confirmation doorhanger, make sure we don't leave it open.
let popupHidden = promisePopupHidden(panel);
panel.hidePopup();
await popupHidden;
BrowserTestUtils.removeTab(tab);
// extension to open the newtab
let opener = ExtensionTestUtils.loadExtension({
async background() {
let newtab = await browser.tabs.create({});
browser.test.assertTrue(
newtab.id !== browser.tabs.TAB_ID_NONE,
"New tab was created."
);
await browser.tabs.remove(newtab.id);
browser.test.sendMessage("complete");
},
});
function listener(msg) {
Assert.ok(!/may not load or link to moz-extension/.test(msg.message));
}
Services.console.registerListener(listener);
registerCleanupFunction(() => {
Services.console.unregisterListener(listener);
});
await opener.startup();
await opener.awaitMessage("complete");
await extension.awaitMessage("newtab-loaded");
await opener.unload();
await extension.unload();
});

View File

@@ -3,112 +3,171 @@
"use strict";
add_task(async function testWindowCreate() {
let pageExt = ExtensionTestUtils.loadExtension({
manifest: {
applications: { gecko: { id: "page@mochitest" } },
protocol_handlers: [
{
protocol: "ext+foo",
name: "a foo protocol handler",
uriTemplate: "page.html?val=%s",
},
],
},
files: {
"page.html": `<html><head>
<meta charset="utf-8">
</head></html>`,
},
});
await pageExt.startup();
async function background(OTHER_PAGE) {
browser.test.log(`== using ${OTHER_PAGE}`);
const EXTENSION_URL = browser.runtime.getURL("test.html");
const EXT_PROTO = "ext+bar:foo";
const OTHER_PROTO = "ext+foo:bar";
let windows = new (class extends Map {
// eslint-disable-line new-parens
get(id) {
if (!this.has(id)) {
let window = {
tabs: new Map(),
};
window.promise = new Promise(resolve => {
window.resolvePromise = resolve;
});
this.set(id, window);
}
return super.get(id);
}
})();
browser.tabs.onUpdated.addListener((tabId, changed, tab) => {
if (changed.status == "complete" && tab.url !== "about:blank") {
let window = windows.get(tab.windowId);
window.tabs.set(tab.index, tab);
if (window.tabs.size === window.expectedTabs) {
browser.test.log("resolving a window load");
window.resolvePromise(window);
}
}
});
async function create(options) {
browser.test.log(`creating window for ${options.url}`);
let window = await browser.windows.create(options);
let win = windows.get(window.id);
win.id = window.id;
win.expectedTabs = Array.isArray(options.url) ? options.url.length : 1;
return win.promise;
}
function createFail(options) {
return browser.windows
.create(options)
.then(() => {
browser.test.fail(`window opened with ${options.url}`);
})
.catch(() => {
browser.test.succeed(`window could not open with ${options.url}`);
});
}
let TEST_SETS = [
{
name: "Single protocol URL in this extension",
url: EXT_PROTO,
expect: [`${EXTENSION_URL}?val=ext%2Bbar%3Afoo`],
},
{
name: "Single, relative URL",
url: "test.html",
expect: [EXTENSION_URL],
},
{
name: "Single, absolute, extension URL",
url: EXTENSION_URL,
expect: [EXTENSION_URL],
},
{
name: "Single, absolute, other extension URL",
url: OTHER_PAGE,
expect: [OTHER_PAGE],
},
{
name: "Single protocol URL in other extension",
url: OTHER_PROTO,
expect: [`${OTHER_PAGE}?val=ext%2Bfoo%3Abar`],
},
{
name: "multiple urls",
url: [EXT_PROTO, "test.html", EXTENSION_URL, OTHER_PROTO],
expect: [
`${EXTENSION_URL}?val=ext%2Bbar%3Afoo`,
EXTENSION_URL,
EXTENSION_URL,
`${OTHER_PAGE}?val=ext%2Bfoo%3Abar`,
],
},
];
try {
let windows = await Promise.all(
TEST_SETS.map(t => create({ url: t.url }))
);
TEST_SETS.forEach((test, i) => {
test.expect.forEach((expectUrl, x) => {
browser.test.assertEq(
expectUrl,
windows[i].tabs.get(x)?.url,
TEST_SETS[i].name
);
});
});
Promise.all(windows.map(({ id }) => browser.windows.remove(id))).then(
() => {
browser.test.notifyPass("window-create-url");
}
);
// Expecting to fail when opening windows with multiple urls that includes
// other extension urls.
await Promise.all([createFail({ url: [EXTENSION_URL, OTHER_PAGE] })]);
} catch (e) {
browser.test.fail(`${e} :: ${e.stack}`);
browser.test.notifyFail("window-create-url");
}
}
let extension = ExtensionTestUtils.loadExtension({
manifest: {
permissions: ["tabs"],
protocol_handlers: [
{
protocol: "ext+bar",
name: "a bar protocol handler",
uriTemplate: "test.html?val=%s",
},
],
},
background: async function() {
const EXTENSION_URL = browser.runtime.getURL("test.html");
const REMOTE_URL = browser.runtime.getURL("test.html");
let windows = new (class extends Map {
// eslint-disable-line new-parens
get(id) {
if (!this.has(id)) {
let window = {
tabs: new Map(),
};
window.promise = new Promise(resolve => {
window.resolvePromise = resolve;
});
this.set(id, window);
}
return super.get(id);
}
})();
browser.tabs.onUpdated.addListener((tabId, changed, tab) => {
if (changed.status == "complete" && tab.url !== "about:blank") {
let window = windows.get(tab.windowId);
window.tabs.set(tab.index, tab);
if (window.tabs.size === window.expectedTabs) {
window.resolvePromise(window);
}
}
});
async function create(options) {
let window = await browser.windows.create(options);
let win = windows.get(window.id);
win.id = window.id;
win.expectedTabs = Array.isArray(options.url) ? options.url.length : 1;
return win.promise;
}
try {
let windows = await Promise.all([
create({ url: REMOTE_URL }),
create({ url: "test.html" }),
create({ url: EXTENSION_URL }),
create({ url: [REMOTE_URL, "test.html", EXTENSION_URL] }),
]);
browser.test.assertEq(
REMOTE_URL,
windows[0].tabs.get(0).url,
"Single, absolute, remote URL"
);
browser.test.assertEq(
REMOTE_URL,
windows[1].tabs.get(0).url,
"Single, relative URL"
);
browser.test.assertEq(
REMOTE_URL,
windows[2].tabs.get(0).url,
"Single, absolute, extension URL"
);
browser.test.assertEq(
REMOTE_URL,
windows[3].tabs.get(0).url,
"url[0]: Absolute, remote URL"
);
browser.test.assertEq(
EXTENSION_URL,
windows[3].tabs.get(1).url,
"url[1]: Relative URL"
);
browser.test.assertEq(
EXTENSION_URL,
windows[3].tabs.get(2).url,
"url[2]: Absolute, extension URL"
);
Promise.all(windows.map(({ id }) => browser.windows.remove(id))).then(
() => {
browser.test.notifyPass("window-create-url");
}
);
} catch (e) {
browser.test.fail(`${e} :: ${e.stack}`);
browser.test.notifyFail("window-create-url");
}
},
background: `(${background})("moz-extension://${pageExt.uuid}/page.html")`,
files: {
"test.html": `<DOCTYPE html><html><head><meta charset="utf-8"></head></html>`,
"test.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>`,
},
});
await extension.startup();
await extension.awaitFinish("window-create-url");
await extension.unload();
await pageExt.unload();
});

View File

@@ -25,6 +25,8 @@ let image = atob(
const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0))
.buffer;
const ANDROID = navigator.userAgent.includes("Android");
async function testImageLoading(src, expectedAction) {
let imageLoadingPromise = new Promise((resolve, reject) => {
let cleanupListeners;
@@ -373,6 +375,100 @@ add_task(async function test_web_accessible_resources_mixed_content() {
await extension.unload();
});
// test that MV2 extensions continue to open other MV2 extension pages
// when they are not listed in web_accessible_resources.
add_task(async function test_web_accessible_resources_extensions_MV2() {
function background() {
let newtab;
let win;
let expectUrl;
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (tab.url != expectUrl || tab.status !== "complete") {
return;
}
browser.test.sendMessage("onUpdated", tab.url);
});
browser.test.onMessage.addListener(async (msg, url) => {
expectUrl = url;
if (msg == "create") {
newtab = await browser.tabs.create({ url });
browser.test.assertTrue(
newtab.id !== browser.tabs.TAB_ID_NONE,
"New tab was created."
);
} else if (msg == "update") {
await browser.tabs.update(newtab.id, { url });
} else if (msg == "remove") {
await browser.tabs.remove(newtab.id);
newtab = null;
browser.test.sendMessage("completed");
} else if (msg == "open-window") {
win = await browser.windows.create({ url });
} else if (msg == "close-window") {
await browser.windows.remove(win.id);
browser.test.sendMessage("completed");
win = null;
}
});
}
let extension = ExtensionTestUtils.loadExtension({
manifest: {
applications: { gecko: { id: "this-mv2@mochitest" } },
},
background,
files: {
"page.html": `<html><head>
<meta charset="utf-8">
</head></html>`,
},
});
async function testTabsAction(ext, action, url) {
ext.sendMessage(action, url);
let loaded = await ext.awaitMessage("onUpdated");
is(loaded, url, "extension url was loaded");
}
await extension.startup();
let extensionUrl = `moz-extension://${extension.uuid}/page.html`;
// Test opening its own pages
await testTabsAction(extension, "create", extensionUrl);
await testTabsAction(extension, "update", extensionUrl);
extension.sendMessage("remove");
await extension.awaitMessage("completed");
if (!ANDROID) {
await testTabsAction(extension, "open-window", extensionUrl);
extension.sendMessage("close-window");
await extension.awaitMessage("completed");
}
// Extension used to open the homepage in a new window.
let other = ExtensionTestUtils.loadExtension({
manifest: {
permissions: ["tabs", "<all_urls>"],
},
background,
});
await other.startup();
// Test opening another extensions pages
await testTabsAction(other, "create", extensionUrl);
await testTabsAction(other, "update", extensionUrl);
other.sendMessage("remove");
await other.awaitMessage("completed");
if (!ANDROID) {
await testTabsAction(other, "open-window", extensionUrl);
other.sendMessage("close-window");
await other.awaitMessage("completed");
}
await extension.unload();
await other.unload();
});
</script>
</body>