Bug 1898446 - Introduce a new sendAbuseReport() method on the AddonManager web API (mozAddonManager). r=rpl,smaug

Differential Revision: https://phabricator.services.mozilla.com/D209017
This commit is contained in:
William Durand 2024-05-27 14:11:08 +00:00
parent 09a14c53a9
commit a3fca22df4
10 changed files with 584 additions and 30 deletions

View File

@ -56,6 +56,11 @@ dictionary addonInstallOptions {
DOMString? hash = null;
};
dictionary sendAbuseReportOptions {
// This should be an Authorization HTTP header value.
DOMString? authorization = null;
};
[HeaderFile="mozilla/AddonManagerWebAPI.h",
Func="mozilla::AddonManagerWebAPI::IsAPIEnabled",
JSImplementation="@mozilla.org/addon-web-api/manager;1",
@ -79,6 +84,30 @@ interface AddonManager : EventTarget {
* @return A promise that resolves to an instance of AddonInstall.
*/
Promise<AddonInstall> createInstall(optional addonInstallOptions options = {});
/**
* Sends an abuse report to the AMO API.
*
* NOTE: The type for `data` and for the return value are loose because both
* the AMO API might change its response and the caller (AMO frontend) might
* also want to pass slightly different data in the future.
*
* @param addonId
* The ID of the add-on to report.
* @param data
* The caller passes the data to be sent to the AMO API.
* @param options
* Optional - A set of options. It currently only supports
* `authorization`, which is expected to be the value of an
* Authorization HTTP header when provided.
* @return A promise that resolves to the AMO API response, or an error when
* something went wrong.
*/
[NewObject] Promise<any> sendAbuseReport(
DOMString addonId,
record<DOMString, DOMString?> data,
optional sendAbuseReportOptions options = {}
);
};
[ChromeOnly,Exposed=Window,HeaderFile="mozilla/AddonManagerWebAPI.h"]

View File

@ -1859,6 +1859,7 @@ pref("services.common.uptake.sampleRate", 1); // 1%
pref("extensions.abuseReport.enabled", false);
// Whether Firefox integrated abuse reporting feature should be opening the new abuse report form hosted on AMO.
pref("extensions.abuseReport.amoFormURL", "https://addons.mozilla.org/%LOCALE%/%APP%/feedback/addon/%addonID%/");
pref("extensions.addonAbuseReport.url", "https://services.addons.mozilla.org/api/v5/abuse/report/addon/");
// Blocklist preferences
pref("extensions.blocklist.enabled", true);

View File

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
// Maximum length of the string properties sent to the API endpoint.
const MAX_STRING_LENGTH = 255;
@ -16,6 +17,8 @@ const AMO_SUPPORTED_ADDON_TYPES = [
"dictionary",
];
const PREF_ADDON_ABUSE_REPORT_URL = "extensions.addonAbuseReport.url";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
@ -23,6 +26,50 @@ ChromeUtils.defineESModuleGetters(lazy, {
ClientID: "resource://gre/modules/ClientID.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"ADDON_ABUSE_REPORT_URL",
PREF_ADDON_ABUSE_REPORT_URL
);
const ERROR_TYPES = Object.freeze([
"ERROR_CLIENT",
"ERROR_NETWORK",
"ERROR_SERVER",
"ERROR_UNKNOWN",
]);
export class AbuseReportError extends Error {
constructor(errorType, errorInfo = undefined) {
if (!ERROR_TYPES.includes(errorType)) {
throw new Error(`Unexpected AbuseReportError type "${errorType}"`);
}
let message = errorInfo ? `${errorType} - ${errorInfo}` : errorType;
super(message);
this.name = "AbuseReportError";
this.errorType = errorType;
this.errorInfo = errorInfo;
}
}
/**
* Create an error info string from a fetch response object.
*
* @param {Response} response
* A fetch response object to convert into an errorInfo string.
*
* @returns {Promise<string>}
* The errorInfo string to be included in an AbuseReportError.
*/
async function responseToErrorInfo(response) {
return JSON.stringify({
status: response.status,
responseText: await response.text().catch(() => ""),
});
}
/**
* A singleton used to manage abuse reports for add-ons.
*/
@ -37,6 +84,76 @@ export const AbuseReporter = {
return AMO_SUPPORTED_ADDON_TYPES.includes(addonType);
},
/**
* Send an add-on abuse report using the AMO API. The data passed to this
* method might be augmented with report data known by Firefox.
*
* @param {string} addonId
* @param {{[key: string]: string|null}} data
* Abuse report data to be submitting to the AMO API along with the
* additional abuse report data known by Firefox.
* @param {object} [options]
* @param {string} [options.authorization]
* An optional value of an Authorization HTTP header to be set on the
* submission request.
*
* @returns {Promise<object>} Return a promise that resolves to the JSON AMO
* API response (or an error when something went wrong).
*/
async sendAbuseReport(addonId, data, options = {}) {
const rejectReportError = async (errorType, { response } = {}) => {
// Leave errorInfo empty if there is no response or fails to be converted
// into an error info object.
const errorInfo = response
? await responseToErrorInfo(response).catch(() => undefined)
: undefined;
throw new AbuseReportError(errorType, errorInfo);
};
let abuseReport = { addon: addonId, ...data };
// If the add-on is installed, augment the data with internal report data.
const addon = await lazy.AddonManager.getAddonByID(addonId);
if (addon) {
const metadata = await AbuseReporter.getReportData(addon);
abuseReport = { ...abuseReport, ...metadata };
}
const headers = { "Content-Type": "application/json" };
if (options?.authorization?.length) {
headers.authorization = options.authorization;
}
let response;
try {
response = await fetch(lazy.ADDON_ABUSE_REPORT_URL, {
method: "POST",
credentials: "omit",
referrerPolicy: "no-referrer",
headers,
body: JSON.stringify(abuseReport),
});
} catch (err) {
Cu.reportError(err);
return rejectReportError("ERROR_NETWORK");
}
if (response.ok && response.status >= 200 && response.status < 400) {
return response.json();
}
if (response.status >= 400 && response.status < 500) {
return rejectReportError("ERROR_CLIENT", { response });
}
if (response.status >= 500 && response.status < 600) {
return rejectReportError("ERROR_SERVER", { response });
}
return rejectReportError("ERROR_UNKNOWN", { response });
},
/**
* Helper function that retrieves from an addon object all the data to send
* as part of the submission request, besides the `reason`, `message` which are

View File

@ -80,6 +80,7 @@ var AsyncShutdown = realAsyncShutdown;
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
Extension: "resource://gre/modules/Extension.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
@ -3553,6 +3554,10 @@ var AddonManagerInternal = {
});
},
async sendAbuseReport(target, addonId, data, options) {
return lazy.AbuseReporter.sendAbuseReport(addonId, data, options);
},
async addonUninstall(target, id) {
let addon = await AddonManager.getAddonByID(id);
if (!addon) {

View File

@ -244,6 +244,18 @@ export class WebAPI extends APIObject {
});
}
sendAbuseReport(addonId, data, options) {
return this._apiTask(
"sendAbuseReport",
[addonId, data, options],
result => {
// The result below is a JS object coming from the expected AMO API
// endpoint response in JSON format.
return Cu.cloneInto(result, this.window);
}
);
}
eventListenerAdded() {
if (this.listenerCount == 0) {
this.broker.setAddonListener(data => {

View File

@ -175,6 +175,8 @@ https_first_disabled = true
["browser_webapi_install_disabled.js"]
["browser_webapi_sendAbuseReport.js"]
["browser_webapi_theme.js"]
["browser_webapi_uninstall.js"]

View File

@ -64,3 +64,22 @@ add_task(async function test_report_action_hidden_on_langpack_addons() {
);
await closeAboutAddons();
});
add_task(async function test_report_action_hidden_on_system_addons() {
await openAboutAddons("extension");
await AbuseReportTestUtils.assertReportActionHidden(
gManagerWindow,
EXT_SYSTEM_ADDON_ID
);
await closeAboutAddons();
});
add_task(async function test_report_action_hidden_on_builtin_addons() {
const DEFAULT_BUILTIN_THEME_ID = "default-theme@mozilla.org";
await openAboutAddons("theme");
await AbuseReportTestUtils.assertReportActionHidden(
gManagerWindow,
DEFAULT_BUILTIN_THEME_ID
);
await closeAboutAddons();
});

View File

@ -0,0 +1,109 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const { AddonTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/AddonTestUtils.sys.mjs"
);
const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
const ADDON_ID = "@test-extension-to-report";
AddonTestUtils.initMochitest(this);
const server = AddonTestUtils.createHttpServer({ hosts: ["test.addons.org"] });
let apiRequestHandler;
server.registerPathHandler("/api/abuse/report/addon/", (request, response) => {
apiRequestHandler(request, response);
});
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
["extensions.webapi.testing", true],
[
"extensions.addonAbuseReport.url",
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
"http://test.addons.org/api/abuse/report/addon/",
],
],
});
});
add_task(async function test_mozAddonManager_sendAbuseReport() {
apiRequestHandler = (req, res) => {
res.setStatusLine(req.httpVersion, 200, "OK");
res.setHeader("Content-Type", "application/json", false);
res.write('{"ok":true}');
};
await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
const extension = ExtensionTestUtils.loadExtension({
manifest: {
name: "some extension reported",
browser_specific_settings: { gecko: { id: ADDON_ID } },
},
useAddonManager: "temporary",
});
await extension.startup();
const response = await SpecialPowers.spawn(browser, [ADDON_ID], addonId => {
const data = { some: "data" };
return content.navigator.mozAddonManager.sendAbuseReport(addonId, data);
});
Assert.deepEqual(
response,
{ ok: true },
"expected API response to be returned"
);
await extension.unload();
});
});
add_task(async function test_mozAddonManager_sendAbuseReport_error() {
apiRequestHandler = (req, res) => {
res.setStatusLine(req.httpVersion, 400, "BAD REQUEST");
};
await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
const extension = ExtensionTestUtils.loadExtension({
manifest: {
name: "some extension reported",
browser_specific_settings: { gecko: { id: ADDON_ID } },
},
useAddonManager: "temporary",
});
await extension.startup();
const webApiResult = await SpecialPowers.spawn(
browser,
[ADDON_ID],
addonId => {
const data = { some: "data" };
return content.navigator.mozAddonManager
.sendAbuseReport(addonId, data)
.then(
res => ({ gotRejection: false, result: res }),
err => ({
gotRejection: true,
message: err.message,
errorName: err.name,
isErrorInstance: err instanceof content.Error,
})
);
}
);
Assert.deepEqual(
webApiResult,
{
gotRejection: true,
message: 'ERROR_CLIENT - {"status":400,"responseText":""}',
errorName: "Error",
isErrorInstance: true,
},
"expected rejection"
);
await extension.unload();
});
});

View File

@ -15,10 +15,7 @@ const { AddonTestUtils } = ChromeUtils.importESModule(
const EXT_DICTIONARY_ADDON_ID = "fake-dictionary@mochi.test";
const EXT_LANGPACK_ADDON_ID = "fake-langpack@mochi.test";
const EXT_WITH_PRIVILEGED_URL_ID = "ext-with-privileged-url@mochi.test";
const EXT_SYSTEM_ADDON_ID = "test-system-addon@mochi.test";
const EXT_UNSUPPORTED_TYPE_ADDON_ID = "report-unsupported-type@mochi.test";
const THEME_NO_UNINSTALL_ID = "theme-without-perm-can-uninstall@mochi.test";
let gManagerWindow;
@ -37,11 +34,6 @@ async function closeAboutAddons() {
const AbuseReportTestUtils = {
_mockProvider: null,
_mockServer: null,
_abuseRequestHandlers: [],
// Mock addon details API endpoint.
amoAddonDetailsMap: new Map(),
// Setup the test environment by setting the expected prefs and initializing
// MockProvider.
@ -87,21 +79,6 @@ const AbuseReportTestUtils = {
_setupMockProvider() {
this._mockProvider = new MockProvider();
this._mockProvider.createAddons([
{
id: THEME_NO_UNINSTALL_ID,
name: "This theme cannot be uninstalled",
version: "1.1",
creator: { name: "Theme creator", url: "http://example.com/creator" },
type: "theme",
permissions: 0,
},
{
id: EXT_WITH_PRIVILEGED_URL_ID,
name: "This extension has an unexpected privileged creator URL",
version: "1.1",
creator: { name: "creator", url: "about:config" },
type: "extension",
},
{
id: EXT_SYSTEM_ADDON_ID,
name: "This is a system addon",
@ -110,12 +87,6 @@ const AbuseReportTestUtils = {
type: "extension",
isSystem: true,
},
{
id: EXT_UNSUPPORTED_TYPE_ADDON_ID,
name: "This is a fake unsupported addon type",
version: "1.1",
type: "unsupported_addon_type",
},
{
id: EXT_LANGPACK_ADDON_ID,
name: "This is a fake langpack",

View File

@ -2,7 +2,7 @@
* http://creativecommons.org/publicdomain/zero/1.0/
*/
const { AbuseReporter } = ChromeUtils.importESModule(
const { AbuseReporter, AbuseReportError } = ChromeUtils.importESModule(
"resource://gre/modules/AbuseReporter.sys.mjs"
);
@ -17,6 +17,10 @@ const FAKE_INSTALL_INFO = {
source: "fake-Install:Source",
method: "fake:install method",
};
const EXPECTED_API_RESPONSE = {
id: ADDON_ID,
some: "other-props",
};
async function installTestExtension(overrideOptions = {}) {
const extOptions = {
@ -96,9 +100,57 @@ async function assertBaseReportData({ reportData, addon }) {
);
}
async function assertRejectsAbuseReportError(promise, errorType, errorInfo) {
let error;
await Assert.rejects(
promise,
err => {
error = err;
return err instanceof AbuseReportError;
},
`Got an AbuseReportError`
);
equal(error.errorType, errorType, "Got the expected errorType");
equal(error.errorInfo, errorInfo, "Got the expected errorInfo");
ok(
error.message.includes(errorType),
"errorType should be included in the error message"
);
if (errorInfo) {
ok(
error.message.includes(errorInfo),
"errorInfo should be included in the error message"
);
}
}
function handleSubmitRequest({ request, response }) {
response.setStatusLine(request.httpVersion, 200, "OK");
response.setHeader("Content-Type", "application/json", false);
response.write(JSON.stringify(EXPECTED_API_RESPONSE));
}
createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
const server = createHttpServer({ hosts: ["test.addons.org"] });
// Mock abuse report API endpoint.
let apiRequestHandler;
server.registerPathHandler("/api/abuse/report/addon/", (request, response) => {
const stream = request.bodyInputStream;
const buffer = NetUtil.readInputStream(stream, stream.available());
const data = new TextDecoder().decode(buffer);
apiRequestHandler({ data, request, response });
});
add_setup(async () => {
Services.prefs.setCharPref(
"extensions.addonAbuseReport.url",
"http://test.addons.org/api/abuse/report/addon/"
);
await promiseStartupManager();
});
@ -221,3 +273,240 @@ add_task(async function test_normalized_addon_install_source_and_method() {
await assertAddonInstallMethod(test, expect);
}
});
add_task(async function test_sendAbuseReport() {
const { addon, extension } = await installTestExtension();
// Data passed by the caller.
const formData = { "some-data-from-the-caller": true };
// Metadata stored by Gecko, only passed when the add-on is installed, which
// is what this test case verifies.
//
// NOTE: We JSON stringify + parse to get rid of the undefined values, which
// we do not send to the server.
const metadata = JSON.parse(
JSON.stringify(await AbuseReporter.getReportData(addon))
);
// Register a request handler to (1) access the data submitted and (2) return
// a 200 response.
let dataSubmitted;
apiRequestHandler = ({ data, request, response }) => {
Assert.equal(
request.getHeader("content-type"),
"application/json",
"expected content-type header"
);
Assert.ok(
!request.hasHeader("authorization"),
"expected no authorization header"
);
dataSubmitted = JSON.parse(data);
handleSubmitRequest({ request, response });
};
const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData);
Assert.deepEqual(
response,
EXPECTED_API_RESPONSE,
"expected successful response"
);
Assert.deepEqual(
dataSubmitted,
{
...formData,
...metadata,
// The add-on ID is unconditionally passed as `addon` on purpose. See:
// https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
addon: ADDON_ID,
},
"expected the right data to be sent to the server"
);
await extension.unload();
});
add_task(async function test_sendAbuseReport_addon_not_installed() {
const formData = { "some-data-from-the-caller": true };
// Register a request handler to (1) access the data submitted and (2) return
// a 200 response.
let dataSubmitted;
apiRequestHandler = ({ data, request, response }) => {
Assert.equal(
request.getHeader("content-type"),
"application/json",
"expected content-type header"
);
Assert.ok(
!request.hasHeader("authorization"),
"expected no authorization header"
);
dataSubmitted = JSON.parse(data);
handleSubmitRequest({ request, response });
};
const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData);
Assert.deepEqual(
response,
EXPECTED_API_RESPONSE,
"expected successful response"
);
Assert.deepEqual(
dataSubmitted,
{
...formData,
// The add-on ID is unconditionally passed as `addon` on purpose. See:
// https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
addon: ADDON_ID,
},
"expected the right data to be sent to the server"
);
});
add_task(async function test_sendAbuseReport_with_authorization() {
const { addon, extension } = await installTestExtension();
// Data passed by the caller.
const formData = { "some-data-from-the-caller": true };
// Metadata stored by Gecko, only passed when the add-on is installed, which
// is what this test case verifies.
//
// NOTE: We JSON stringify + parse to get rid of the undefined values, which
// we do not send to the server.
const metadata = JSON.parse(
JSON.stringify(await AbuseReporter.getReportData(addon))
);
const authorization = "some authorization header";
// Register a request handler to (1) access the data submitted and (2) return
// a 200 response.
let dataSubmitted;
apiRequestHandler = ({ data, request, response }) => {
Assert.equal(
request.getHeader("content-type"),
"application/json",
"expected content-type header"
);
Assert.equal(
request.getHeader("authorization"),
authorization,
"expected authorization header"
);
dataSubmitted = JSON.parse(data);
handleSubmitRequest({ request, response });
};
const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData, {
authorization,
});
Assert.deepEqual(
response,
EXPECTED_API_RESPONSE,
"expected successful response"
);
Assert.deepEqual(
dataSubmitted,
{
...formData,
...metadata,
// The add-on ID is unconditionally passed as `addon` on purpose. See:
// https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
addon: ADDON_ID,
},
"expected the right data to be sent to the server"
);
await extension.unload();
});
add_task(async function test_sendAbuseReport_errors() {
const { extension } = await installTestExtension();
async function testErrorCode({
responseStatus,
responseText = "",
expectedErrorType,
expectedErrorInfo,
expectRequest = true,
}) {
info(
`Test expected AbuseReportError on response status "${responseStatus}"`
);
let requestReceived = false;
apiRequestHandler = ({ request, response }) => {
requestReceived = true;
response.setStatusLine(request.httpVersion, responseStatus, "Error");
response.write(responseText);
};
const promise = AbuseReporter.sendAbuseReport(ADDON_ID, {});
if (typeof expectedErrorType === "string") {
// Assert a specific AbuseReportError errorType.
await assertRejectsAbuseReportError(
promise,
expectedErrorType,
expectedErrorInfo
);
} else {
// Assert on a given Error class.
await Assert.rejects(
promise,
expectedErrorType,
"expected correct Error class"
);
}
equal(
requestReceived,
expectRequest,
`${expectRequest ? "" : "Not "}received a request as expected`
);
}
await testErrorCode({
responseStatus: 500,
responseText: "A server error",
expectedErrorType: "ERROR_SERVER",
expectedErrorInfo: JSON.stringify({
status: 500,
responseText: "A server error",
}),
});
await testErrorCode({
responseStatus: 404,
responseText: "Not found error",
expectedErrorType: "ERROR_CLIENT",
expectedErrorInfo: JSON.stringify({
status: 404,
responseText: "Not found error",
}),
});
// Test response with unexpected status code.
await testErrorCode({
responseStatus: 604,
responseText: "An unexpected status code",
expectedErrorType: "ERROR_UNKNOWN",
expectedErrorInfo: JSON.stringify({
status: 604,
responseText: "An unexpected status code",
}),
});
// Test response status 200 with invalid json data.
await testErrorCode({
responseStatus: 200,
expectedErrorType: /SyntaxError: JSON.parse/,
});
// Test on invalid url.
Services.prefs.setCharPref(
"extensions.addonAbuseReport.url",
"invalid-protocol://abuse-report"
);
await testErrorCode({
expectedErrorType: "ERROR_NETWORK",
expectRequest: false,
});
await extension.unload();
});