diff options
Diffstat (limited to 'toolkit/mozapps/extensions/AbuseReporter.jsm')
-rw-r--r-- | toolkit/mozapps/extensions/AbuseReporter.jsm | 672 |
1 files changed, 672 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/AbuseReporter.jsm b/toolkit/mozapps/extensions/AbuseReporter.jsm new file mode 100644 index 0000000000..d68e50cf7f --- /dev/null +++ b/toolkit/mozapps/extensions/AbuseReporter.jsm @@ -0,0 +1,672 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["AbuseReporter", "AbuseReportError"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +Cu.importGlobalProperties(["fetch"]); + +const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url"; +const PREF_AMO_DETAILS_API_URL = "extensions.abuseReport.amoDetailsURL"; + +// Name associated with the report dialog window. +const DIALOG_WINDOW_NAME = "addons-abuse-report-dialog"; + +// Maximum length of the string properties sent to the API endpoint. +const MAX_STRING_LENGTH = 255; + +// Minimum time between report submissions (in ms). +const MIN_MS_BETWEEN_SUBMITS = 30000; + +// The addon types currently supported by the integrated abuse report panel. +const SUPPORTED_ADDON_TYPES = ["extension", "theme"]; + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AMTelemetry: "resource://gre/modules/AddonManager.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + ClientID: "resource://gre/modules/ClientID.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "ABUSE_REPORT_URL", + PREF_ABUSE_REPORT_URL +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "AMO_DETAILS_API_URL", + PREF_AMO_DETAILS_API_URL +); + +const PRIVATE_REPORT_PROPS = Symbol("privateReportProps"); + +const ERROR_TYPES = Object.freeze([ + "ERROR_ABORTED_SUBMIT", + "ERROR_ADDON_NOTFOUND", + "ERROR_CLIENT", + "ERROR_NETWORK", + "ERROR_UNKNOWN", + "ERROR_RECENT_SUBMIT", + "ERROR_SERVER", + "ERROR_AMODETAILS_NOTFOUND", + "ERROR_AMODETAILS_FAILURE", +]); + +class AbuseReportError extends Error { + constructor(errorType, errorInfo = undefined) { + if (!ERROR_TYPES.includes(errorType)) { + throw new Error(`Unknown 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(err => ""), + }); +} + +/** + * A singleton object used to create new AbuseReport instances for a given addonId + * and enforce a minium amount of time between two report submissions . + */ +const AbuseReporter = { + _lastReportTimestamp: null, + + // Error types. + updateLastReportTimestamp() { + this._lastReportTimestamp = Date.now(); + }, + + getTimeFromLastReport() { + const currentTimestamp = Date.now(); + if (this._lastReportTimestamp > currentTimestamp) { + // Reset the last report timestamp if it is in the future. + this._lastReportTimestamp = null; + } + + if (!this._lastReportTimestamp) { + return Infinity; + } + + return currentTimestamp - this._lastReportTimestamp; + }, + + /** + * Create an AbuseReport instance, given the addonId and a reportEntryPoint. + * + * @param {string} addonId + * The id of the addon to create the report instance for. + * @param {object} options + * @param {string} options.reportEntryPoint + * An identifier that represent the entry point for the report flow. + * + * @returns {Promise<AbuseReport>} + * Returns a promise that resolves to an instance of the AbuseReport + * class, which represent an ongoing report. + */ + async createAbuseReport(addonId, { reportEntryPoint } = {}) { + let addon = await AddonManager.getAddonByID(addonId); + + if (!addon) { + // The addon isn't installed, query the details from the AMO API endpoint. + addon = await this.queryAMOAddonDetails(addonId, reportEntryPoint); + } + + if (!addon) { + AMTelemetry.recordReportEvent({ + addonId, + errorType: "ERROR_ADDON_NOTFOUND", + reportEntryPoint, + }); + throw new AbuseReportError("ERROR_ADDON_NOTFOUND"); + } + + const reportData = await this.getReportData(addon); + + return new AbuseReport({ + addon, + reportData, + reportEntryPoint, + }); + }, + + /** + * Retrieves the addon details from the AMO API endpoint, used to create + * abuse reports on non-installed addon-ons. + * + * For the addon details that may be translated (e.g. addon name, description etc.) + * the function will try to retrieve the string localized in the same locale used + * by Gecko (and fallback to "en-US" if that locale is unavailable). + * + * The addon creator properties are set to the first author available. + * + * @param {string} addonId + * The id of the addon to retrieve the details available on AMO. + * @param {string} reportEntryPoint + * The entry point for the report flow (to be included in the telemetry + * recorded in case of failures). + * + * @returns {Promise<AMOAddonDetails|null>} + * Returns a promise that resolves to an AMOAddonDetails object, + * which has the subset of the AddonWrapper properties which are + * needed by the abuse report panel or the report data sent to + * the abuse report API endpoint), or null if it fails to + * retrieve the details from AMO. + * + * @typedef {object} AMOAddonDetails + * @prop {string} id + * @prop {string} name + * @prop {string} version + * @prop {string} description + * @prop {string} type + * @prop {string} iconURL + * @prop {string} homepageURL + * @prop {string} supportURL + * @prop {AMOAddonCreator} creator + * @prop {boolean} isRecommended + * @prop {number} signedState=AddonManager.SIGNEDSTATE_UNKNOWN + * @prop {object} installTelemetryInfo={ source: "not_installed" } + * + * @typedef {object} AMOAddonCreator + * @prop {string} name + * @prop {string} url + */ + async queryAMOAddonDetails(addonId, reportEntryPoint) { + let details; + try { + // This should be the API endpoint documented at: + // https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail + details = await fetch(`${AMO_DETAILS_API_URL}/${addonId}`, { + credentials: "omit", + referrerPolicy: "no-referrer", + headers: { "Content-Type": "application/json" }, + }).then(async response => { + if (response.status === 200) { + return response.json(); + } + + let errorInfo = await responseToErrorInfo(response).catch( + err => undefined + ); + + if (response.status === 404) { + // Record a different telemetry event for 404 errors. + throw new AbuseReportError("ERROR_AMODETAILS_NOTFOUND", errorInfo); + } + + throw new AbuseReportError("ERROR_AMODETAILS_FAILURE", errorInfo); + }); + } catch (err) { + // Log the original error in the browser console. + Cu.reportError(err); + + AMTelemetry.recordReportEvent({ + addonId, + errorType: err.errorType || "ERROR_AMODETAILS_FAILURE", + reportEntryPoint, + }); + + return null; + } + + const locale = Services.locale.appLocaleAsBCP47; + + // Get a string value from a translated value + // (https://addons-server.readthedocs.io/en/latest/topics/api/overview.html#api-overview-translations) + const getTranslatedValue = value => { + if (typeof value === "string") { + return value; + } + return value && (value[locale] || value["en-US"]); + }; + + const getAuthorField = fieldName => + details.authors && details.authors[0] && details.authors[0][fieldName]; + + // Normalize type "statictheme" (which is the type used on the AMO API side) + // into "theme" (because it is the type we use and expect on the Firefox side + // for this addon type). + const addonType = details.type === "statictheme" ? "theme" : details.type; + + return { + id: addonId, + name: getTranslatedValue(details.name), + version: details.current_version.version, + description: getTranslatedValue(details.summary), + type: addonType, + iconURL: details.icon_url, + homepageURL: getTranslatedValue(details.homepage), + supportURL: getTranslatedValue(details.support_url), + // Set the addon creator to the first author in the AMO details. + creator: { + name: getAuthorField("name"), + url: getAuthorField("url"), + }, + isRecommended: details.is_recommended, + // Set signed state to unknown because it isn't installed. + signedState: AddonManager.SIGNEDSTATE_UNKNOWN, + // Set the installTelemetryInfo.source to "not_installed". + installTelemetryInfo: { source: "not_installed" }, + }; + }, + + /** + * 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 + * going to be received from the submit method of the report object returned + * by `createAbuseReport`. + * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html) + * + * @param {AddonWrapper} addon + * The addon object to collect the detail from. + * + * @return {object} + * An object that contains the collected details. + */ + async getReportData(addon) { + const truncateString = text => + typeof text == "string" ? text.slice(0, MAX_STRING_LENGTH) : text; + + // Normalize addon_install_source and addon_install_method values + // as expected by the server API endpoint. Returns null if the + // value is not a string. + const normalizeValue = text => + typeof text == "string" + ? text.toLowerCase().replace(/[- :]/g, "_") + : null; + + const installInfo = addon.installTelemetryInfo || {}; + + const data = { + addon: addon.id, + addon_version: addon.version, + addon_name: truncateString(addon.name), + addon_summary: truncateString(addon.description), + addon_install_origin: + addon.sourceURI && truncateString(addon.sourceURI.spec), + install_date: addon.installDate && addon.installDate.toISOString(), + addon_install_source: normalizeValue(installInfo.source), + addon_install_source_url: + installInfo.sourceURL && truncateString(installInfo.sourceURL), + addon_install_method: normalizeValue(installInfo.method), + }; + + switch (addon.signedState) { + case AddonManager.SIGNEDSTATE_BROKEN: + data.addon_signature = "broken"; + break; + case AddonManager.SIGNEDSTATE_UNKNOWN: + data.addon_signature = "unknown"; + break; + case AddonManager.SIGNEDSTATE_MISSING: + data.addon_signature = "missing"; + break; + case AddonManager.SIGNEDSTATE_PRELIMINARY: + data.addon_signature = "preliminary"; + break; + case AddonManager.SIGNEDSTATE_SIGNED: + data.addon_signature = "signed"; + break; + case AddonManager.SIGNEDSTATE_SYSTEM: + data.addon_signature = "system"; + break; + case AddonManager.SIGNEDSTATE_PRIVILEGED: + data.addon_signature = "privileged"; + break; + default: + data.addon_signature = `unknown: ${addon.signedState}`; + } + + // Set "curated" as addon_signature on recommended addons + // (addon.isRecommended internally checks that the addon is also + // signed correctly). + if (addon.isRecommended) { + data.addon_signature = "curated"; + } + + data.client_id = await ClientID.getClientIdHash(); + + data.app = Services.appinfo.name.toLowerCase(); + data.appversion = Services.appinfo.version; + data.lang = Services.locale.appLocaleAsBCP47; + data.operating_system = AppConstants.platform; + data.operating_system_version = Services.sysinfo.getProperty("version"); + + return data; + }, + + /** + * Helper function that returns a reference to a report dialog window + * already opened (if any). + * + * @returns {Window?} + */ + getOpenDialog() { + return Services.ww.getWindowByName(DIALOG_WINDOW_NAME, null); + }, + + /** + * Helper function that opens an abuse report form in a new dialog window. + * + * @param {string} addonId + * The addonId being reported. + * @param {string} reportEntryPoint + * The entry point from which the user has triggered the abuse report + * flow. + * @param {XULElement} browser + * The browser element (if any) that is opening the report window. + * + * @return {Promise<AbuseReportDialog>} + * Returns an AbuseReportDialog object, rejects if it fails to open + * the dialog. + * + * @typedef {object} AbuseReportDialog + * An object that represents the abuse report dialog. + * @prop {function} close + * A method that closes the report dialog (used by the caller + * to close the dialog when the user chooses to close the window + * that started the abuse report flow). + * @prop {Promise<AbuseReport|undefined} promiseReport + * A promise resolved to an AbuseReport instance if the report should + * be submitted, or undefined if the user has cancelled the report. + * Rejects if it fails to create an AbuseReport instance or to open + * the abuse report window. + */ + async openDialog(addonId, reportEntryPoint, browser) { + const chromeWin = browser && browser.ownerGlobal; + if (!chromeWin) { + throw new Error("Abuse Reporter dialog cancelled, opener tab closed"); + } + + const dialogWin = this.getOpenDialog(); + + if (dialogWin) { + // If an abuse report dialog is already open, cancel the + // previous report flow and start a new one. + const { + deferredReport, + promiseReport, + } = dialogWin.arguments[0].wrappedJSObject; + deferredReport.resolve({ userCancelled: true }); + await promiseReport; + } + + const report = await AbuseReporter.createAbuseReport(addonId, { + reportEntryPoint, + }); + + if (!SUPPORTED_ADDON_TYPES.includes(report.addon.type)) { + throw new Error( + `Addon type "${report.addon.type}" is not currently supported by the integrated abuse reporting feature` + ); + } + + const params = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + + const dialogInit = { + report, + openWebLink(url) { + chromeWin.openWebLinkIn(url, "tab", { + relatedToCurrent: true, + }); + }, + }; + + params.appendElement(dialogInit); + + let win; + function closeDialog() { + if (win && !win.closed) { + win.close(); + } + } + + const promiseReport = new Promise((resolve, reject) => { + dialogInit.deferredReport = { resolve, reject }; + }).then( + ({ userCancelled }) => { + closeDialog(); + return userCancelled ? undefined : report; + }, + err => { + Cu.reportError( + `Unexpected abuse report panel error: ${err} :: ${err.stack}` + ); + closeDialog(); + return Promise.reject({ + message: "Unexpected abuse report panel error", + }); + } + ); + + const promiseReportPanel = new Promise((resolve, reject) => { + dialogInit.deferredReportPanel = { resolve, reject }; + }); + + dialogInit.promiseReport = promiseReport; + dialogInit.promiseReportPanel = promiseReportPanel; + + win = Services.ww.openWindow( + chromeWin, + "chrome://mozapps/content/extensions/abuse-report-frame.html", + DIALOG_WINDOW_NAME, + // Set the dialog window options (including a reasonable initial + // window height size, eventually adjusted by the panel once it + // has been rendered its content). + "dialog,centerscreen,height=700", + params + ); + + return { + close: closeDialog, + promiseReport, + + // Properties used in tests + promiseReportPanel, + window: win, + }; + }, +}; + +/** + * Represents an ongoing abuse report. Instances of this class are created + * by the `AbuseReporter.createAbuseReport` method. + * + * This object is used by the reporting UI panel and message bars to: + * + * - get an errorType in case of a report creation error (e.g. because of a + * previously submitted report) + * - get the addon details used inside the reporting panel + * - submit the abuse report (and re-submit if a previous submission failed + * and the user choose to retry to submit it again) + * - abort an ongoing submission + * + * @param {object} options + * @param {AddonWrapper|null} options.addon + * AddonWrapper instance for the extension/theme being reported. + * (May be null if the extension has not been found). + * @param {object|null} options.reportData + * An object which contains addon and environment details to send as part of a submission + * (may be null if the report has a createErrorType). + * @param {string} options.reportEntryPoint + * A string that identify how the report has been triggered. + */ +class AbuseReport { + constructor({ addon, createErrorType, reportData, reportEntryPoint }) { + this[PRIVATE_REPORT_PROPS] = { + aborted: false, + abortController: new AbortController(), + addon, + reportData, + reportEntryPoint, + // message and reason are initially null, and then set by the panel + // using the related set method. + message: null, + reason: null, + }; + } + + recordTelemetry(errorType) { + const { addon, reportEntryPoint } = this; + AMTelemetry.recordReportEvent({ + addonId: addon.id, + addonType: addon.type, + errorType, + reportEntryPoint, + }); + } + + /** + * Submit the current report, given a reason and a message. + * + * @returns {Promise<void>} + * Resolves once the report has been successfully submitted. + * It rejects with an AbuseReportError if the report couldn't be + * submitted for a known reason (or another Error type otherwise). + */ + async submit() { + const { + aborted, + abortController, + message, + reason, + reportData, + reportEntryPoint, + } = this[PRIVATE_REPORT_PROPS]; + + // Record telemetry event and throw an AbuseReportError. + const rejectReportError = async (errorType, { response } = {}) => { + this.recordTelemetry(errorType); + + // 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(err => undefined) + : undefined; + + throw new AbuseReportError(errorType, errorInfo); + }; + + if (aborted) { + // Report aborted before being actually submitted. + return rejectReportError("ERROR_ABORTED_SUBMIT"); + } + + // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS. + let msFromLastReport = AbuseReporter.getTimeFromLastReport(); + if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) { + return rejectReportError("ERROR_RECENT_SUBMIT"); + } + + let response; + try { + response = await fetch(ABUSE_REPORT_URL, { + signal: abortController.signal, + method: "POST", + credentials: "omit", + referrerPolicy: "no-referrer", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...reportData, + report_entry_point: reportEntryPoint, + message, + reason, + }), + }); + } catch (err) { + if (err.name === "AbortError") { + return rejectReportError("ERROR_ABORTED_SUBMIT"); + } + Cu.reportError(err); + return rejectReportError("ERROR_NETWORK"); + } + + if (response.ok && response.status >= 200 && response.status < 400) { + // Ensure that the response is also a valid json format. + try { + await response.json(); + } catch (err) { + this.recordTelemetry("ERROR_UNKNOWN"); + throw err; + } + AbuseReporter.updateLastReportTimestamp(); + this.recordTelemetry(); + return undefined; + } + + if (response.status >= 400 && response.status < 500) { + return rejectReportError("ERROR_CLIENT", { response }); + } + + if (response.status >= 500 && response.status < 600) { + return rejectReportError("ERROR_SERVER", { response }); + } + + // We got an unexpected HTTP status code. + return rejectReportError("ERROR_UNKNOWN", { response }); + } + + /** + * Abort the report submission. + */ + abort() { + const { abortController } = this[PRIVATE_REPORT_PROPS]; + abortController.abort(); + this[PRIVATE_REPORT_PROPS].aborted = true; + } + + get addon() { + return this[PRIVATE_REPORT_PROPS].addon; + } + + get reportEntryPoint() { + return this[PRIVATE_REPORT_PROPS].reportEntryPoint; + } + + /** + * Set the open message (called from the panel when the user submit the report) + * + * @parm {string} message + * An optional string which contains a description for the reported issue. + */ + setMessage(message) { + this[PRIVATE_REPORT_PROPS].message = message; + } + + /** + * Set the report reason (called from the panel when the user submit the report) + * + * @parm {string} reason + * String identifier for the report reason. + */ + setReason(reason) { + this[PRIVATE_REPORT_PROPS].reason = reason; + } +} |