Bug 1792138: Show the extension's name in permission prompts for opening external links. r=ckerschb,robwu,fluent-reviewers,pbz,flod

In order to handle the content script case correctly we must expose the
contentScriptAddonPolicy to JavaScript. With that we can always see what
extension is trying to perform an action and use its name rather than internal
ID in the dialog.

Differential Revision: https://phabricator.services.mozilla.com/D161282
This commit is contained in:
Dave Townsend 2023-01-07 17:53:19 +00:00
parent 7e0768e157
commit b752598495
10 changed files with 556 additions and 17 deletions

View file

@ -147,6 +147,34 @@ add_task(async function testWindowCreate() {
}
}
// Watch for any permission prompts to show up and accept them.
let dialogCount = 0;
let windowObserver = window => {
// This listener will go away when the window is closed so there is no need
// to explicitely remove it.
// eslint-disable-next-line mozilla/balanced-listeners
window.addEventListener("dialogopen", event => {
dialogCount++;
let { dialog } = event.detail;
Assert.equal(
dialog?._openedURL,
"chrome://mozapps/content/handling/permissionDialog.xhtml",
"Should only ever see the permission dialog"
);
let dialogEl = dialog._frame.contentDocument.querySelector("dialog");
Assert.ok(dialogEl, "Dialog element should exist");
dialogEl.setAttribute("buttondisabledaccept", false);
dialogEl.acceptDialog();
});
};
Services.obs.addObserver(windowObserver, "browser-delayed-startup-finished");
registerCleanupFunction(() => {
Services.obs.removeObserver(
windowObserver,
"browser-delayed-startup-finished"
);
});
let extension = ExtensionTestUtils.loadExtension({
manifest: {
permissions: ["tabs"],
@ -170,4 +198,13 @@ add_task(async function testWindowCreate() {
await extension.awaitFinish("window-create-url");
await extension.unload();
await pageExt.unload();
Assert.equal(
dialogCount,
2,
"Expected to see the right number of permission prompts."
);
// Make sure windows have been released before finishing.
Cu.forceGC();
});

View file

@ -1133,6 +1133,13 @@ nsresult BasePrincipal::GetAddonPolicy(
return NS_OK;
}
nsresult BasePrincipal::GetContentScriptAddonPolicy(
extensions::WebExtensionPolicy** aResult) {
RefPtr<extensions::WebExtensionPolicy> policy(ContentScriptAddonPolicy());
policy.forget(aResult);
return NS_OK;
}
extensions::WebExtensionPolicy* BasePrincipal::AddonPolicy() {
AssertIsOnMainThread();
RefPtr<extensions::WebExtensionPolicyCore> core = AddonPolicyCore();

View file

@ -127,6 +127,8 @@ class BasePrincipal : public nsJSPrincipals {
bool allowIfInheritsPrincipal,
uint64_t innerWindowID) final;
NS_IMETHOD GetAddonPolicy(extensions::WebExtensionPolicy** aResult) final;
NS_IMETHOD GetContentScriptAddonPolicy(
extensions::WebExtensionPolicy** aResult) final;
NS_IMETHOD GetIsNullPrincipal(bool* aResult) override;
NS_IMETHOD GetIsContentPrincipal(bool* aResult) override;
NS_IMETHOD GetIsExpandedPrincipal(bool* aResult) override;

View file

@ -551,6 +551,7 @@ interface nsIPrincipal : nsISupports
* NOTE: Main-Thread Only.
*/
readonly attribute WebExtensionPolicy addonPolicy;
readonly attribute WebExtensionPolicy contentScriptAddonPolicy;
/**
* Gets the id of the user context this principal is inside. If this

View file

@ -233,6 +233,9 @@ const INSTALL_AND_UPDATE_STARTUP_REASONS = new Set([
"ADDON_DOWNGRADE",
]);
const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
const PERMISSION_KEY_DELIMITER = "^";
// Returns true if the extension is owned by Mozilla (is either privileged,
// using one of the @mozilla.com/@mozilla.org protected addon id suffixes).
//
@ -574,6 +577,16 @@ var ExtensionAddonObserver = {
Services.perms.removeFromPrincipal(principal, "persistent-storage");
}
// Clear any protocol handler permissions granted to this add-on.
let permissions = Services.perms.getAllWithTypePrefix(
PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER
);
for (let perm of permissions) {
if (perm.principal.equalsURI(baseURI)) {
Services.perms.removePermission(perm);
}
}
if (!Services.prefs.getBoolPref(LEAVE_UUID_PREF, false)) {
// Clear the entry in the UUID map
UUIDMap.remove(addon.id);

View file

@ -16,8 +16,11 @@
/* eslint-disable mozilla/balanced-listeners */
function protocolChromeScript() {
const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
const PERMISSION_KEY_DELIMITER = "^";
/* eslint-env mozilla/chrome-script */
addMessageListener("setup", protocol => {
addMessageListener("setup", ({ protocol, principalOrigins }) => {
let data = {};
const protoSvc = Cc[
"@mozilla.org/uriloader/external-protocol-service;1"
@ -41,6 +44,33 @@ function protocolChromeScript() {
].getService(Ci.nsIHandlerService);
handlerSvc.store(protoInfo);
for (let origin of principalOrigins) {
let principal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(origin),
{}
);
let pbPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(origin),
{
privateBrowsingId: 1,
}
);
let permKey =
PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + protocol;
Services.perms.addFromPrincipal(
principal,
permKey,
Services.perms.ALLOW_ACTION,
Services.perms.EXPIRE_NEVER
);
Services.perms.addFromPrincipal(
pbPrincipal,
permKey,
Services.perms.ALLOW_ACTION,
Services.perms.EXPIRE_NEVER
);
}
sendAsyncMessage("handlerData", data);
});
addMessageListener("setPreferredAction", data => {
@ -136,7 +166,13 @@ add_task(async function test_protocolHandler() {
let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
let msg = chromeScript.promiseOneMessage("handlerData");
chromeScript.sendAsyncMessage("setup", "ext+foo");
chromeScript.sendAsyncMessage("setup", {
protocol: "ext+foo",
principalOrigins: [
`moz-extension://${extension.uuid}/`,
`moz-extension://${pb_extension.uuid}/`,
],
});
let data = await msg;
ok(
data.preferredAction,
@ -312,7 +348,10 @@ add_task(async function test_protocolHandler_two() {
let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
let msg = chromeScript.promiseOneMessage("handlerData");
chromeScript.sendAsyncMessage("setup", "ext+foo");
chromeScript.sendAsyncMessage("setup", {
protocol: "ext+foo",
principalOrigins: [],
});
let data = await msg;
ok(
data.preferredAction,
@ -516,7 +555,7 @@ add_task(async function test_ftp_protocolHandler() {
await msg;
msg = chromeScript.promiseOneMessage("handlerData");
chromeScript.sendAsyncMessage("setup", "ftp");
chromeScript.sendAsyncMessage("setup", { protocol: "ftp", principalOrigins: [] });
let data = await msg;
ok(
data.preferredAction,

View file

@ -7,6 +7,7 @@
## $host - the hostname that is initiating the request
## $scheme - the type of link that's being opened.
## $appName - Name of the application that will be opened.
## $extension - Name of extension that initiated the request
permission-dialog-description =
Allow this site to open the { $scheme } link?
@ -17,6 +18,9 @@ permission-dialog-description-file =
permission-dialog-description-host =
Allow { $host } to open the { $scheme } link?
permission-dialog-description-extension =
Allow the extension { $extension } to open the { $scheme } link?
permission-dialog-description-app =
Allow this site to open the { $scheme } link with { $appName }?
@ -26,6 +30,9 @@ permission-dialog-description-host-app =
permission-dialog-description-file-app =
Allow this file to open the { $scheme } link with { $appName }?
permission-dialog-description-extension-app =
Allow the extension { $extension } to open the { $scheme } link with { $appName }?
## Please keep the emphasis around the hostname and scheme (ie the
## `<strong>` HTML tags). Please also keep the hostname as close to the start
## of the sentence as your language's grammar allows.
@ -36,6 +43,9 @@ permission-dialog-remember =
permission-dialog-remember-file =
Always allow this file to open <strong>{ $scheme }</strong> links
permission-dialog-remember-extension =
Always allow this extension to open <strong>{ $scheme }</strong> links
##
permission-dialog-btn-open-link =

View file

@ -293,10 +293,6 @@ class nsContentDispatchChooser {
return false;
}
if (aPrincipal.isAddonOrExpandedAddonPrincipal) {
return true;
}
let key = this._getSkipProtoDialogPermissionKey(scheme);
return (
Services.perms.testPermissionFromPrincipal(aPrincipal, key) ===
@ -396,16 +392,28 @@ class nsContentDispatchChooser {
return;
}
let principal = aPrincipal;
// If this action was triggered by an extension content script then set the
// permission on the extension's principal.
let addonPolicy = aPrincipal.contentScriptAddonPolicy;
if (addonPolicy) {
principal = Services.scriptSecurityManager.principalWithOA(
addonPolicy.extension.principal,
principal.originAttributes
);
}
let permKey = this._getSkipProtoDialogPermissionKey(aScheme);
if (aAllow) {
Services.perms.addFromPrincipal(
aPrincipal,
principal,
permKey,
Services.perms.ALLOW_ACTION,
Services.perms.EXPIRE_NEVER
);
} else {
Services.perms.removeFromPrincipal(aPrincipal, permKey);
Services.perms.removeFromPrincipal(principal, permKey);
}
}
@ -415,11 +423,18 @@ class nsContentDispatchChooser {
* @returns {boolean} - true if we can store permissions, false otherwise.
*/
_isSupportedPrincipal(aPrincipal) {
return (
aPrincipal &&
["http", "https", "moz-extension", "file"].some(scheme =>
aPrincipal.schemeIs(scheme)
)
if (!aPrincipal) {
return false;
}
// If this is an add-on content script then we will be able to store
// permissions against the add-on's principal.
if (aPrincipal.contentScriptAddonPolicy) {
return true;
}
return ["http", "https", "moz-extension", "file"].some(scheme =>
aPrincipal.schemeIs(scheme)
);
}
}

View file

@ -23,6 +23,8 @@ let dialog = {
this._handlerInfo = handler.QueryInterface(Ci.nsIHandlerInfo);
this._principal = principal?.QueryInterface(Ci.nsIPrincipal);
this._addonPolicy =
this._principal?.addonPolicy ?? this._principal?.contentScriptAddonPolicy;
this._browsingContext = browsingContext;
this._outArgs = outArgs.QueryInterface(Ci.nsIWritablePropertyBag);
this._preferredHandlerName = preferredHandlerName;
@ -82,6 +84,13 @@ let dialog = {
* the triggering principal and the preferred application handler.
*/
get l10nDescriptionId() {
if (this._addonPolicy) {
if (this._preferredHandlerName) {
return "permission-dialog-description-extension-app";
}
return "permission-dialog-description-extension";
}
if (this._principal?.schemeIs("file")) {
if (this._preferredHandlerName) {
return "permission-dialog-description-file-app";
@ -116,6 +125,9 @@ let dialog = {
return null;
}
if (this._addonPolicy) {
return "permission-dialog-remember-extension";
}
if (this._principal.schemeIs("file")) {
return "permission-dialog-remember-file";
}
@ -168,6 +180,7 @@ let dialog = {
document.l10n.setAttributes(description, this.l10nDescriptionId, {
host,
scheme,
extension: this._addonPolicy?.name,
appName: this._preferredHandlerName,
});

View file

@ -7,6 +7,10 @@ var { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
const { ExtensionPermissions } = ChromeUtils.import(
"resource://gre/modules/ExtensionPermissions.jsm"
);
let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
Ci.nsIHandlerService
);
@ -209,6 +213,7 @@ async function testOpenProto(
actionCheckbox,
actionConfirm,
actionChangeApp,
checkContents,
} = permDialogOptions;
if (actionChangeApp) {
@ -256,6 +261,10 @@ async function testOpenProto(
);
}
if (checkContents) {
checkContents(dialogEl);
}
if (actionChangeApp) {
let dialogClosedPromise = waitForProtocolPermissionDialog(browser, false);
changeAppLink.click();
@ -780,13 +789,254 @@ add_task(async function test_non_standard_protocol() {
});
/**
* Tests that we skip the permission dialog for extension callers.
* Tests that we show the permission dialog for extension content scripts.
*/
add_task(async function test_extension_principal() {
add_task(async function test_extension_content_script_permission() {
let scheme = TEST_PROTOS[0];
await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
let testExtension;
await testOpenProto(browser, scheme, {
triggerLoad: async () => {
let uri = `${scheme}://test`;
const EXTENSION_DATA = {
manifest: {
content_scripts: [
{
matches: [browser.currentURI.spec],
js: ["navigate.js"],
},
],
browser_specific_settings: {
gecko: { id: "allowed@mochi.test" },
},
},
files: {
"navigate.js": `window.location.href = "${uri}";`,
},
useAddonManager: "permanent",
};
testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
await testExtension.startup();
},
permDialogOptions: {
hasCheckbox: true,
chooserIsNext: true,
hasChangeApp: false,
actionCheckbox: true,
actionConfirm: true,
checkContents: dialogEl => {
let description = dialogEl.querySelector("#description");
let { id, args } = description.ownerDocument.l10n.getAttributes(
description
);
is(
id,
"permission-dialog-description-extension",
"Should be using the correct string."
);
is(
args.extension,
"Generated extension",
"Should have the correct extension name."
);
},
},
chooserDialogOptions: {
hasCheckbox: true,
actionConfirm: false, // Cancel dialog
},
});
let extensionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{}
);
let extensionPrivatePrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{ privateBrowsingId: 1 }
);
let key = getSkipProtoDialogPermissionKey(scheme);
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.ALLOW_ACTION,
"Should have permanently allowed the extension"
);
is(
Services.perms.testPermissionFromPrincipal(
extensionPrivatePrincipal,
key
),
Services.perms.UNKNOWN_ACTION,
"Should not have changed the private principal permission"
);
is(
Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
Services.perms.UNKNOWN_ACTION,
"Should not have allowed the page"
);
await testExtension.unload();
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.UNKNOWN_ACTION,
"Should have cleared the extension's normal principal permission"
);
is(
Services.perms.testPermissionFromPrincipal(
extensionPrivatePrincipal,
key
),
Services.perms.UNKNOWN_ACTION,
"Should have cleared the private browsing principal"
);
});
});
/**
* Tests that we show the permission dialog for extension content scripts.
*/
add_task(async function test_extension_private_content_script_permission() {
let scheme = TEST_PROTOS[0];
let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
await BrowserTestUtils.withNewTab(
{ gBrowser: win.gBrowser, url: ORIGIN1 },
async browser => {
let testExtension;
await testOpenProto(browser, scheme, {
triggerLoad: async () => {
let uri = `${scheme}://test`;
const EXTENSION_DATA = {
manifest: {
content_scripts: [
{
matches: [browser.currentURI.spec],
js: ["navigate.js"],
},
],
browser_specific_settings: {
gecko: { id: "allowed@mochi.test" },
},
},
files: {
"navigate.js": `window.location.href = "${uri}";`,
},
useAddonManager: "permanent",
};
testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
await testExtension.startup();
let perms = {
permissions: ["internal:privateBrowsingAllowed"],
origins: [],
};
await ExtensionPermissions.add("allowed@mochi.test", perms);
let addon = await AddonManager.getAddonByID("allowed@mochi.test");
await addon.reload();
},
permDialogOptions: {
hasCheckbox: true,
chooserIsNext: true,
hasChangeApp: false,
actionCheckbox: true,
actionConfirm: true,
checkContents: dialogEl => {
let description = dialogEl.querySelector("#description");
let { id, args } = description.ownerDocument.l10n.getAttributes(
description
);
is(
id,
"permission-dialog-description-extension",
"Should be using the correct string."
);
is(
args.extension,
"Generated extension",
"Should have the correct extension name."
);
},
},
chooserDialogOptions: {
hasCheckbox: true,
actionConfirm: false, // Cancel dialog
},
});
let extensionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{}
);
let extensionPrivatePrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{ privateBrowsingId: 1 }
);
let key = getSkipProtoDialogPermissionKey(scheme);
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.UNKNOWN_ACTION,
"Should not have changed the extension's normal principal permission"
);
is(
Services.perms.testPermissionFromPrincipal(
extensionPrivatePrincipal,
key
),
Services.perms.ALLOW_ACTION,
"Should have allowed the private browsing principal"
);
is(
Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
Services.perms.UNKNOWN_ACTION,
"Should not have allowed the page"
);
await testExtension.unload();
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.UNKNOWN_ACTION,
"Should have cleared the extension's normal principal permission"
);
is(
Services.perms.testPermissionFromPrincipal(
extensionPrivatePrincipal,
key
),
Services.perms.UNKNOWN_ACTION,
"Should have cleared the private browsing principal"
);
}
);
await BrowserTestUtils.closeWindow(win);
});
/**
* Tests that we do not show the permission dialog for extension content scripts
* when the page already has permission.
*/
add_task(async function test_extension_allowed_content() {
let scheme = TEST_PROTOS[0];
await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
let testExtension;
let key = getSkipProtoDialogPermissionKey(scheme);
Services.perms.addFromPrincipal(
PRINCIPAL1,
key,
Services.perms.ALLOW_ACTION,
Services.perms.EXPIRE_NEVER
);
await testOpenProto(browser, scheme, {
triggerLoad: async () => {
let uri = `${scheme}://test`;
@ -814,6 +1064,158 @@ add_task(async function test_extension_principal() {
},
});
let extensionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{}
);
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.UNKNOWN_ACTION,
"Should not have permanently allowed the extension"
);
await testExtension.unload();
Services.perms.removeFromPrincipal(PRINCIPAL1, key);
});
});
/**
* Tests that we do not show the permission dialog for extension content scripts
* when the extension already has permission.
*/
add_task(async function test_extension_allowed_extension() {
let scheme = TEST_PROTOS[0];
await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
let testExtension;
let key = getSkipProtoDialogPermissionKey(scheme);
await testOpenProto(browser, scheme, {
triggerLoad: async () => {
const EXTENSION_DATA = {
manifest: {
permissions: [`${ORIGIN1}/*`],
},
background() {
browser.test.onMessage.addListener(async (msg, uri) => {
switch (msg) {
case "engage":
browser.tabs.executeScript({
code: `window.location.href = "${uri}";`,
});
break;
default:
browser.test.fail(`Unexpected message received: ${msg}`);
}
});
},
};
testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
await testExtension.startup();
let extensionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{}
);
Services.perms.addFromPrincipal(
extensionPrincipal,
key,
Services.perms.ALLOW_ACTION,
Services.perms.EXPIRE_NEVER
);
testExtension.sendMessage("engage", `${scheme}://test`);
},
chooserDialogOptions: {
hasCheckbox: true,
actionConfirm: false, // Cancel dialog
},
});
await testExtension.unload();
Services.perms.removeFromPrincipal(PRINCIPAL1, key);
});
});
/**
* Tests that we show the permission dialog for extensions directly opening a
* protocol.
*/
add_task(async function test_extension_principal() {
let scheme = TEST_PROTOS[0];
await BrowserTestUtils.withNewTab(ORIGIN1, async browser => {
let testExtension;
await testOpenProto(browser, scheme, {
triggerLoad: async () => {
const EXTENSION_DATA = {
background() {
browser.test.onMessage.addListener(async (msg, url) => {
switch (msg) {
case "engage":
browser.tabs.update({
url,
});
break;
default:
browser.test.fail(`Unexpected message received: ${msg}`);
}
});
},
};
testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
await testExtension.startup();
testExtension.sendMessage("engage", `${scheme}://test`);
},
permDialogOptions: {
hasCheckbox: true,
chooserIsNext: true,
hasChangeApp: false,
actionCheckbox: true,
actionConfirm: true,
checkContents: dialogEl => {
let description = dialogEl.querySelector("#description");
let { id, args } = description.ownerDocument.l10n.getAttributes(
description
);
is(
id,
"permission-dialog-description-extension",
"Should be using the correct string."
);
is(
args.extension,
"Generated extension",
"Should have the correct extension name."
);
},
},
chooserDialogOptions: {
hasCheckbox: true,
actionConfirm: false, // Cancel dialog
},
});
let extensionPrincipal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(`moz-extension://${testExtension.uuid}/`),
{}
);
let key = getSkipProtoDialogPermissionKey(scheme);
is(
Services.perms.testPermissionFromPrincipal(extensionPrincipal, key),
Services.perms.ALLOW_ACTION,
"Should have permanently allowed the extension"
);
is(
Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key),
Services.perms.UNKNOWN_ACTION,
"Should not have allowed the page"
);
await testExtension.unload();
});
});